about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorKartik K. Agaram <vc@akkartik.com>2022-09-03 14:13:22 -0700
committerKartik K. Agaram <vc@akkartik.com>2022-09-03 14:13:22 -0700
commite1c5a42f311fdafd88506726bbe480f3fcc2d1a3 (patch)
tree6628729cc55947d0bd5d306704e88b57680c3514
parent9c72ff1bb4fc1ba08acfb0324079da6fe49f3a4a (diff)
downloadview.love-e1c5a42f311fdafd88506726bbe480f3fcc2d1a3.tar.gz
editing source code from within the app
integrated from pong.love via text.love:
  https://merveilles.town/@akkartik/108933336531898243
-rw-r--r--Manual_tests.md21
-rw-r--r--README.md5
-rw-r--r--app.lua29
-rw-r--r--colorize.lua83
-rw-r--r--commands.lua100
-rw-r--r--edit.lua9
-rw-r--r--file.lua1
-rw-r--r--keychord.lua14
-rw-r--r--log.lua34
-rw-r--r--log_browser.lua316
-rw-r--r--main.lua367
-rw-r--r--run.lua182
-rw-r--r--run_tests.lua (renamed from main_tests.lua)0
-rw-r--r--search.lua20
-rw-r--r--source.lua358
-rw-r--r--source_edit.lua377
-rw-r--r--source_file.lua89
-rw-r--r--source_tests.lua77
-rw-r--r--source_text.lua1561
-rw-r--r--source_text_tests.lua1609
-rw-r--r--source_undo.lua110
-rw-r--r--text.lua5
22 files changed, 5164 insertions, 203 deletions
diff --git a/Manual_tests.md b/Manual_tests.md
index e258940..358ea8b 100644
--- a/Manual_tests.md
+++ b/Manual_tests.md
@@ -3,17 +3,28 @@ program before it ever runs. However, some things don't have tests yet, either
 because I don't know how to test them or because I've been lazy. I'll at least
 record those here.
 
-* Initializing settings:
-    - from previous session
-        - Filename as absolute path
-        - Filename as relative path
-    - from defaults
+Startup:
+    - terminal log shows unit tests running
+
+Initializing settings:
+    - delete app settings, start; window opens running the text editor
+    - quit while running the text editor, restart; window opens running the text editor in same position+dimensions
+    - quit while editing source (color; no drawings; no selection), restart; window opens editing source in same position+dimensions
+    - start out running the text editor, move window, press ctrl+e twice; window is running text editor in same position+dimensions
+    - start out editing source, move window, press ctrl+e twice; window is editing source in same position+dimensions
+    - no log file; switching to source works
+
+Code loading:
+* run love with directory; text editor runs
+* run love with zip file; text editor runs
 
 * How the screen looks. Our tests use a level of indirection to check text and
   graphics printed to screen, but not the precise pixels they translate to.
     - where exactly the cursor is drawn to highlight a given character
     - analogously, how a shape precisely looks as you draw it
 
+* start out running the text editor, press ctrl+e to edit source, make a change to the source, press ctrl+e twice to return to the source editor; the change should be preserved.
+
 ### Other compromises
 
 Lua is dynamically typed. Tests can't patch over lack of type-checking.
diff --git a/README.md b/README.md
index 880b507..8dfa137 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ While editing text:
 * `ctrl+z` to undo, `ctrl+y` to redo
 * `ctrl+=` to zoom in, `ctrl+-` to zoom out, `ctrl+0` to reset zoom
 * `alt+right`/`alt+left` to jump to the next/previous word, respectively
+* `ctrl+e` to modify the sources
 
 For shortcuts while editing drawings, consult the online help. Either:
 * hover on a drawing and hit `ctrl+h`, or
@@ -78,6 +79,10 @@ found anything amiss: http://akkartik.name/contact
 
 * No scrollbars yet. That stuff is hard.
 
+* There are some temporary limitations when editing sources:
+    - no line drawings
+    - no selecting text
+
 ## Mirrors and Forks
 
 Updates to lines.love can be downloaded from the following mirrors in addition
diff --git a/app.lua b/app.lua
index cac1303..2135fb0 100644
--- a/app.lua
+++ b/app.lua
@@ -1,4 +1,4 @@
--- main entrypoint for LÖVE
+-- love.run: main entrypoint function for LÖVE
 --
 -- Most apps can just use the default, but we need to override it to
 -- install a test harness.
@@ -11,13 +11,10 @@
 --
 -- Scroll below this function for more details.
 function love.run()
+  App.snapshot_love()
   -- Tests always run at the start.
-  App.run_tests()
-
+  App.run_tests_and_initialize()
 --?   print('==')
-  App.disable_tests()
-  App.initialize_globals()
-  App.initialize(love.arg.parseGameArguments(arg), arg)
 
   love.timer.step()
   local dt = 0
@@ -123,6 +120,26 @@ end
 
 App = {screen={}}
 
+-- save/restore various framework globals we care about -- only on very first load
+function App.snapshot_love()
+  if Love_snapshot then return end
+  Love_snapshot = {}
+  -- save the entire initial font; it doesn't seem reliably recreated using newFont
+  Love_snapshot.initial_font = love.graphics.getFont()
+end
+
+function App.undo_initialize()
+  love.graphics.setFont(Love_snapshot.initial_font)
+end
+
+function App.run_tests_and_initialize()
+  App.load()
+  App.run_tests()
+  App.disable_tests()
+  App.initialize_globals()
+  App.initialize(love.arg.parseGameArguments(arg), arg)
+end
+
 function App.initialize_for_test()
   App.screen.init({width=100, height=50})
   App.screen.contents = {}  -- clear screen
diff --git a/colorize.lua b/colorize.lua
new file mode 100644
index 0000000..c0d2117
--- /dev/null
+++ b/colorize.lua
@@ -0,0 +1,83 @@
+-- State transitions while colorizing a single line.
+-- Just for comments and strings.
+-- Limitation: each fragment gets a uniform color so we can only change color
+-- at word boundaries.
+Next_state = {
+  normal={
+    {prefix='--', target='comment'},
+    {prefix='"', target='dstring'},
+    {prefix="'", target='sstring'},
+  },
+  dstring={
+    {suffix='"', target='normal'},
+  },
+  sstring={
+    {suffix="'", target='normal'},
+  },
+  -- comments are a sink
+}
+
+Comments_color = {r=0, g=0, b=1}
+String_color = {r=0, g=0.5, b=0.5}
+Divider_color = {r=0.7, g=0.7, b=0.7}
+
+Colors = {
+  normal=Text_color,
+  comment=Comments_color,
+  sstring=String_color,
+  dstring=String_color
+}
+
+Current_state = 'normal'
+
+function initialize_color()
+--?   print('new line')
+  Current_state = 'normal'
+end
+
+function select_color(frag)
+--?   print('before', '^'..frag..'$', Current_state)
+  switch_color_based_on_prefix(frag)
+--?   print('using color', Current_state, Colors[Current_state])
+  App.color(Colors[Current_state])
+  switch_color_based_on_suffix(frag)
+--?   print('state after suffix', Current_state)
+end
+
+function switch_color_based_on_prefix(frag)
+  if Next_state[Current_state] == nil then
+    return
+  end
+  frag = rtrim(frag)
+  for _,edge in pairs(Next_state[Current_state]) do
+    if edge.prefix and find(frag, edge.prefix, nil, --[[plain]] true) == 1 then
+      Current_state = edge.target
+      break
+    end
+  end
+end
+
+function switch_color_based_on_suffix(frag)
+  if Next_state[Current_state] == nil then
+    return
+  end
+  frag = rtrim(frag)
+  for _,edge in pairs(Next_state[Current_state]) do
+    if edge.suffix and rfind(frag, edge.suffix, nil, --[[plain]] true) == #frag then
+      Current_state = edge.target
+      break
+    end
+  end
+end
+
+function trim(s)
+  return s:gsub('^%s+', ''):gsub('%s+$', '')
+end
+
+function ltrim(s)
+  return s:gsub('^%s+', '')
+end
+
+function rtrim(s)
+  return s:gsub('%s+$', '')
+end
diff --git a/commands.lua b/commands.lua
new file mode 100644
index 0000000..037205f
--- /dev/null
+++ b/commands.lua
@@ -0,0 +1,100 @@
+Menu_background_color = {r=0.6, g=0.8, b=0.6}
+Menu_border_color = {r=0.6, g=0.7, b=0.6}
+Menu_command_color = {r=0.2, g=0.2, b=0.2}
+Menu_highlight_color = {r=0.5, g=0.7, b=0.3}
+
+function source.draw_menu_bar()
+  if App.run_tests then return end  -- disable in tests
+  App.color(Menu_background_color)
+  love.graphics.rectangle('fill', 0,0, App.screen.width, Menu_status_bar_height)
+  App.color(Menu_border_color)
+  love.graphics.rectangle('line', 0,0, App.screen.width, Menu_status_bar_height)
+  App.color(Menu_command_color)
+  Menu_cursor = 5
+  if Show_file_navigator then
+    source.draw_file_navigator()
+    return
+  end
+  add_hotkey_to_menu('ctrl+e: run')
+  if Focus == 'edit' then
+    add_hotkey_to_menu('ctrl+g: switch file')
+    if Show_log_browser_side then
+      add_hotkey_to_menu('ctrl+l: hide log browser')
+    else
+      add_hotkey_to_menu('ctrl+l: show log browser')
+    end
+    if Editor_state.expanded then
+      add_hotkey_to_menu('ctrl+b: collapse debug prints')
+    else
+      add_hotkey_to_menu('ctrl+b: expand debug prints')
+    end
+    add_hotkey_to_menu('ctrl+d: create/edit debug print')
+    add_hotkey_to_menu('ctrl+f: find in file')
+    add_hotkey_to_menu('alt+left alt+right: prev/next word')
+  elseif Focus == 'log_browser' then
+    -- nothing yet
+  else
+    assert(false, 'unknown focus "'..Focus..'"')
+  end
+  add_hotkey_to_menu('ctrl+z ctrl+y: undo/redo')
+  add_hotkey_to_menu('ctrl+x ctrl+c ctrl+v: cut/copy/paste')
+  add_hotkey_to_menu('ctrl+= ctrl+- ctrl+0: zoom')
+end
+
+function add_hotkey_to_menu(s)
+  if Text_cache[s] == nil then
+    Text_cache[s] = App.newText(love.graphics.getFont(), s)
+  end
+  local width = App.width(Text_cache[s])
+  if Menu_cursor + width > App.screen.width - 5 then
+    return
+  end
+  App.color(Menu_command_color)
+  App.screen.draw(Text_cache[s], Menu_cursor,5)
+  Menu_cursor = Menu_cursor + width + 30
+end
+
+function source.draw_file_navigator()
+  for i,file in ipairs(File_navigation.candidates) do
+    if file == 'source' then
+      App.color(Menu_border_color)
+      love.graphics.line(Menu_cursor-10,2, Menu_cursor-10,Menu_status_bar_height-2)
+    end
+    add_file_to_menu(file, i == File_navigation.index)
+  end
+end
+
+function add_file_to_menu(s, cursor_highlight)
+  if Text_cache[s] == nil then
+    Text_cache[s] = App.newText(love.graphics.getFont(), s)
+  end
+  local width = App.width(Text_cache[s])
+  if Menu_cursor + width > App.screen.width - 5 then
+    return
+  end
+  if cursor_highlight then
+    App.color(Menu_highlight_color)
+    love.graphics.rectangle('fill', Menu_cursor-5,5-2, App.width(Text_cache[s])+5*2,Editor_state.line_height+2*2)
+  end
+  App.color(Menu_command_color)
+  App.screen.draw(Text_cache[s], Menu_cursor,5)
+  Menu_cursor = Menu_cursor + width + 30
+end
+
+function keychord_pressed_on_file_navigator(chord, key)
+  if chord == 'escape' then
+    Show_file_navigator = false
+  elseif chord == 'return' then
+    local candidate = guess_source(File_navigation.candidates[File_navigation.index]..'.lua')
+    source.switch_to_file(candidate)
+    Show_file_navigator = false
+  elseif chord == 'left' then
+    if File_navigation.index > 1 then
+      File_navigation.index = File_navigation.index-1
+    end
+  elseif chord == 'right' then
+    if File_navigation.index < #File_navigation.candidates then
+      File_navigation.index = File_navigation.index+1
+    end
+  end
+end
diff --git a/edit.lua b/edit.lua
index f5dc1f2..87cf6c2 100644
--- a/edit.lua
+++ b/edit.lua
@@ -20,15 +20,6 @@ Drawing_padding_height = Drawing_padding_top + Drawing_padding_bottom
 
 Same_point_distance = 4  -- pixel distance at which two points are considered the same
 
-utf8 = require 'utf8'
-
-require 'file'
-require 'text'
-require 'drawing'
-require 'geom'
-require 'help'
-require 'icons'
-
 edit = {}
 
 -- run in both tests and a real run
diff --git a/file.lua b/file.lua
index 6b36b0f..4e268a7 100644
--- a/file.lua
+++ b/file.lua
@@ -50,7 +50,6 @@ function save_to_disk(State)
   outfile:close()
 end
 
-json = require 'json'
 function load_drawing(infile_next_line)
   local drawing = {mode='drawing', h=256/2, points={}, shapes={}, pending={}}
   while true do
diff --git a/keychord.lua b/keychord.lua
index ba0a47c..7be57d2 100644
--- a/keychord.lua
+++ b/keychord.lua
@@ -56,9 +56,17 @@ end
 array = {}
 
 function array.find(arr, elem)
-  for i,x in ipairs(arr) do
-    if x == elem then
-      return i
+  if type(elem) == 'function' then
+    for i,x in ipairs(arr) do
+      if elem(x) then
+        return i
+      end
+    end
+  else
+    for i,x in ipairs(arr) do
+      if x == elem then
+        return i
+      end
     end
   end
   return nil
diff --git a/log.lua b/log.lua
new file mode 100644
index 0000000..f59449c
--- /dev/null
+++ b/log.lua
@@ -0,0 +1,34 @@
+function log(stack_frame_index, obj)
+	local info = debug.getinfo(stack_frame_index, 'Sl')
+	local msg
+	if type(obj) == 'string' then
+		msg = obj
+	else
+		msg = json.encode(obj)
+	end
+	love.filesystem.append('log', info.short_src..':'..info.currentline..': '..msg..'\n')
+end
+
+-- for section delimiters we'll use specific Unicode box characters
+function log_start(name, stack_frame_index)
+	if stack_frame_index == nil then
+		stack_frame_index = 3
+	end
+	log(stack_frame_index, '\u{250c} ' .. name)
+end
+function log_end(name, stack_frame_index)
+	if stack_frame_index == nil then
+		stack_frame_index = 3
+	end
+	log(stack_frame_index, '\u{2518} ' .. name)
+end
+
+function log_new(name, stack_frame_index)
+	if stack_frame_index == nil then
+		stack_frame_index = 4
+	end
+	log_end(name, stack_frame_index)
+	log_start(name, stack_frame_index)
+end
+
+-- vim:noexpandtab
diff --git a/log_browser.lua b/log_browser.lua
new file mode 100644
index 0000000..f65117f
--- /dev/null
+++ b/log_browser.lua
@@ -0,0 +1,316 @@
+-- environment for immutable logs
+-- optionally reads extensions for rendering some types from the source codebase that generated them
+--
+-- We won't care too much about long, wrapped lines. If they lines get too
+-- long to manage, you need a better, graphical rendering for them. Load
+-- functions to render them into the log_render namespace.
+
+function source.initialize_log_browser_side()
+  Log_browser_state = edit.initialize_state(Margin_top, Editor_state.right + Margin_right + Margin_left, (Editor_state.right+Margin_right)*2, Editor_state.font_height, Editor_state.line_height)
+  Log_browser_state.filename = 'log'
+  load_from_disk(Log_browser_state)  -- TODO: pay no attention to Fold
+  log_browser.parse(Log_browser_state)
+  Text.redraw_all(Log_browser_state)
+  Log_browser_state.screen_top1 = {line=1, pos=1}
+  Log_browser_state.cursor1 = {line=1, pos=nil}
+end
+
+Section_stack = {}
+Section_border_color = {r=0.7, g=0.7, b=0.7}
+Cursor_line_background_color = {r=0.7, g=0.7, b=0, a=0.1}
+
+Section_border_padding_horizontal = 30  -- TODO: adjust this based on font height (because we draw text vertically along the borders
+Section_border_padding_vertical = 15  -- TODO: adjust this based on font height
+
+log_browser = {}
+
+function log_browser.parse(State)
+  for _,line in ipairs(State.lines) do
+    if line.data ~= '' then
+      line.filename, line.line_number, line.data = line.data:match('%[string "([^:]*)"%]:([^:]*):%s*(.*)')
+      line.filename = guess_source(line.filename)
+      line.line_number = tonumber(line.line_number)
+      if line.data:sub(1,1) == '{' then
+        local data = json.decode(line.data)
+        if log_render[data.name] then
+          line.data = data
+        end
+        line.section_stack = table.shallowcopy(Section_stack)
+      elseif line.data:match('\u{250c}') then
+        line.section_stack = table.shallowcopy(Section_stack)  -- as it is at the beginning
+        local section_name = line.data:match('\u{250c}%s*(.*)')
+        table.insert(Section_stack, {name=section_name})
+        line.section_begin = true
+        line.section_name = section_name
+        line.data = nil
+      elseif line.data:match('\u{2518}') then
+        local section_name = line.data:match('\u{2518}%s*(.*)')
+        if array.find(Section_stack, function(x) return x.name == section_name end) then
+          while table.remove(Section_stack).name ~= section_name do
+            --
+          end
+          line.section_end = true
+          line.section_name = section_name
+          line.data = nil
+        end
+        line.section_stack = table.shallowcopy(Section_stack)
+      else
+        -- string
+        line.section_stack = table.shallowcopy(Section_stack)
+      end
+    else
+      line.section_stack = {}
+    end
+  end
+end
+
+function table.shallowcopy(x)
+  return {unpack(x)}
+end
+
+function guess_source(filename)
+  local possible_source = filename:gsub('%.lua$', '%.splua')
+  if file_exists(possible_source) then
+    return possible_source
+  else
+    return filename
+  end
+end
+
+function log_browser.draw(State)
+  assert(#State.lines == #State.line_cache)
+  local mouse_line_index = log_browser.line_index(State, App.mouse_x(), App.mouse_y())
+  local y = State.top
+  for line_index = State.screen_top1.line,#State.lines do
+    App.color(Text_color)
+    local line = State.lines[line_index]
+    if y + State.line_height > App.screen.height then break end
+    local height = State.line_height
+    if should_show(line) then
+      local xleft = render_stack_left_margin(State, line_index, line, y)
+      local xright = render_stack_right_margin(State, line_index, line, y)
+      if line.section_name then
+        App.color(Section_border_color)
+        local section_text = to_text(line.section_name)
+        if line.section_begin then
+          local sectiony = y+Section_border_padding_vertical
+          love.graphics.line(xleft,sectiony, xleft,y+State.line_height)
+          love.graphics.line(xright,sectiony, xright,y+State.line_height)
+          love.graphics.line(xleft,sectiony, xleft+50-2,sectiony)
+          love.graphics.draw(section_text, xleft+50,y)
+          love.graphics.line(xleft+50+App.width(section_text)+2,sectiony, xright,sectiony)
+        else assert(line.section_end)
+          local sectiony = y+State.line_height-Section_border_padding_vertical
+          love.graphics.line(xleft,y, xleft,sectiony)
+          love.graphics.line(xright,y, xright,sectiony)
+          love.graphics.line(xleft,sectiony, xleft+50-2,sectiony)
+          love.graphics.draw(section_text, xleft+50,y)
+          love.graphics.line(xleft+50+App.width(section_text)+2,sectiony, xright,sectiony)
+        end
+      else
+        if type(line.data) == 'string' then
+          local old_left, old_right = State.left,State.right
+          State.left,State.right = xleft,xright
+          y = Text.draw(State, line_index, y, --[[startpos]] 1)
+          State.left,State.right = old_left,old_right
+        else
+          height = log_render[line.data.name](line.data, xleft, y, xright-xleft)
+        end
+      end
+      if App.mouse_x() > Log_browser_state.left and line_index == mouse_line_index then
+        App.color(Cursor_line_background_color)
+        love.graphics.rectangle('fill', xleft,y, xright-xleft, height)
+      end
+      y = y + height
+    end
+  end
+end
+
+function render_stack_left_margin(State, line_index, line, y)
+  if line.section_stack == nil then
+    -- assertion message
+    for k,v in pairs(line) do
+      print(k)
+    end
+  end
+  App.color(Section_border_color)
+  for i=1,#line.section_stack do
+    local x = State.left + (i-1)*Section_border_padding_horizontal
+    love.graphics.line(x,y, x,y+log_browser.height(State, line_index))
+    if y < 30 then
+      love.graphics.print(line.section_stack[i].name, x+State.font_height+5, y+5, --[[vertically]] math.pi/2)
+    end
+    if y > App.screen.height-log_browser.height(State, line_index) then
+      love.graphics.print(line.section_stack[i].name, x+State.font_height+5, App.screen.height-App.width(to_text(line.section_stack[i].name))-5, --[[vertically]] math.pi/2)
+    end
+  end
+  return log_browser.left_margin(State, line)
+end
+
+function render_stack_right_margin(State, line_index, line, y)
+  App.color(Section_border_color)
+  for i=1,#line.section_stack do
+    local x = State.right - (i-1)*Section_border_padding_horizontal
+    love.graphics.line(x,y, x,y+log_browser.height(State, line_index))
+    if y < 30 then
+      love.graphics.print(line.section_stack[i].name, x, y+5, --[[vertically]] math.pi/2)
+    end
+    if y > App.screen.height-log_browser.height(State, line_index) then
+      love.graphics.print(line.section_stack[i].name, x, App.screen.height-App.width(to_text(line.section_stack[i].name))-5, --[[vertically]] math.pi/2)
+    end
+  end
+  return log_browser.right_margin(State, line)
+end
+
+function should_show(line)
+  -- Show a line if every single section it's in is expanded.
+  for i=1,#line.section_stack do
+    local section = line.section_stack[i]
+    if not section.expanded then
+      return false
+    end
+  end
+  return true
+end
+
+function log_browser.left_margin(State, line)
+  return State.left + #line.section_stack*Section_border_padding_horizontal
+end
+
+function log_browser.right_margin(State, line)
+  return State.right - #line.section_stack*Section_border_padding_horizontal
+end
+
+function log_browser.update(State, dt)
+end
+
+function log_browser.quit(State)
+end
+
+function log_browser.mouse_pressed(State, x,y, mouse_button)
+  local line_index = log_browser.line_index(State, x,y)
+  if line_index == nil then
+    -- below lower margin
+    return
+  end
+  -- leave some space to click without focusing
+  local line = State.lines[line_index]
+  local xleft = log_browser.left_margin(State, line)
+  local xright = log_browser.right_margin(State, line)
+  if x < xleft or x > xright then
+    return
+  end
+  -- if it's a section begin/end and the section is collapsed, expand it
+  -- TODO: how to collapse?
+  if line.section_begin or line.section_end then
+    -- HACK: get section reference from next/previous line
+    local new_section
+    if line.section_begin then
+      if line_index < #State.lines then
+        local next_section_stack = State.lines[line_index+1].section_stack
+        if next_section_stack then
+          new_section = next_section_stack[#next_section_stack]
+        end
+      end
+    elseif line.section_end then
+      if line_index > 1 then
+        local previous_section_stack = State.lines[line_index-1].section_stack
+        if previous_section_stack then
+          new_section = previous_section_stack[#previous_section_stack]
+        end
+      end
+    end
+    if new_section and new_section.expanded == nil then
+      new_section.expanded = true
+      return
+    end
+  end
+  -- open appropriate file in source side
+  if line.filename ~= Editor_state.filename then
+    source.switch_to_file(line.filename)
+  end
+  -- set cursor
+  Editor_state.cursor1 = {line=line.line_number, pos=1, posB=nil}
+  -- make sure it's visible
+  -- TODO: handle extremely long lines
+  Editor_state.screen_top1.line = math.max(0, Editor_state.cursor1.line-5)
+  -- show cursor
+  Focus = 'edit'
+  -- expand B side
+  Editor_state.expanded = true
+end
+
+function log_browser.line_index(State, mx,my)
+  -- duplicate some logic from log_browser.draw
+  local y = State.top
+  for line_index = State.screen_top1.line,#State.lines do
+    local line = State.lines[line_index]
+    if should_show(line) then
+      y = y + log_browser.height(State, line_index)
+      if my < y then
+        return line_index
+      end
+      if y > App.screen.height then break end
+    end
+  end
+end
+
+function log_browser.mouse_released(State, x,y, mouse_button)
+end
+
+function log_browser.textinput(State, t)
+end
+
+function log_browser.keychord_pressed(State, chord, key)
+  -- move
+  if chord == 'up' then
+    while State.screen_top1.line > 1 do
+      State.screen_top1.line = State.screen_top1.line-1
+      if should_show(State.lines[State.screen_top1.line]) then
+        break
+      end
+    end
+  elseif chord == 'down' then
+    while State.screen_top1.line < #State.lines do
+      State.screen_top1.line = State.screen_top1.line+1
+      if should_show(State.lines[State.screen_top1.line]) then
+        break
+      end
+    end
+  elseif chord == 'pageup' then
+    local y = 0
+    while State.screen_top1.line > 1 and y < App.screen.height - 100 do
+      State.screen_top1.line = State.screen_top1.line - 1
+      if should_show(State.lines[State.screen_top1.line]) then
+        y = y + log_browser.height(State, State.screen_top1.line)
+      end
+    end
+  elseif chord == 'pagedown' then
+    local y = 0
+    while State.screen_top1.line < #State.lines and y < App.screen.height - 100 do
+      if should_show(State.lines[State.screen_top1.line]) then
+        y = y + log_browser.height(State, State.screen_top1.line)
+      end
+      State.screen_top1.line = State.screen_top1.line + 1
+    end
+  end
+end
+
+function log_browser.height(State, line_index)
+  local line = State.lines[line_index]
+  if line.data == nil then
+    -- section header
+    return State.line_height
+  elseif type(line.data) == 'string' then
+    return State.line_height
+  else
+    if line.height == nil then
+--?       print('nil line height! rendering off screen to calculate')
+      line.height = log_render[line.data.name](line.data, State.left, App.screen.height, State.right-State.left)
+    end
+    return line.height
+  end
+end
+
+function log_browser.keyreleased(State, key, scancode)
+end
diff --git a/main.lua b/main.lua
index a41d93b..e3f1d93 100644
--- a/main.lua
+++ b/main.lua
@@ -1,217 +1,260 @@
-utf8 = require 'utf8'
-
-require 'app'
-require 'test'
+-- Entrypoint for the app. You can edit this file from within the app if
+-- you're careful.
 
-require 'keychord'
-require 'button'
+-- files that come with LÖVE; we can't edit those from within the app
+utf8 = require 'utf8'
 
-require 'main_tests'
+function load_file_from_source_or_save_directory(filename)
+  local contents = love.filesystem.read(filename)
+  local code, err = loadstring(contents, filename)
+  if code == nil then
+    error(err)
+  end
+  return code()
+end
 
--- delegate most business logic to a layer that can be reused by other projects
-require 'edit'
-Editor_state = {}
+json = load_file_from_source_or_save_directory('json.lua')
 
--- called both in tests and real run
-function App.initialize_globals()
-  -- tests currently mostly clear their own state
+load_file_from_source_or_save_directory('app.lua')
+load_file_from_source_or_save_directory('test.lua')
 
-  -- a few text objects we can avoid recomputing unless the font changes
-  Text_cache = {}
+load_file_from_source_or_save_directory('keychord.lua')
+load_file_from_source_or_save_directory('button.lua')
 
-  -- blinking cursor
-  Cursor_time = 0
-
-  -- for hysteresis in a few places
-  Last_resize_time = App.getTime()
-  Last_focus_time = App.getTime()  -- https://love2d.org/forums/viewtopic.php?p=249700
-end
-
--- called only for real run
-function App.initialize(arg)
-  love.keyboard.setTextInput(true)  -- bring up keyboard on touch screen
-  love.keyboard.setKeyRepeat(true)
-
-  love.graphics.setBackgroundColor(1,1,1)
+-- both sides require (different parts of) the logging framework
+load_file_from_source_or_save_directory('log.lua')
 
+-- but some files we want to only load sometimes
+function App.load()
   if love.filesystem.getInfo('config') then
-    load_settings()
-  else
-    initialize_default_settings()
-  end
-
-  if #arg > 0 then
-    Editor_state.filename = arg[1]
-    load_from_disk(Editor_state)
-    Text.redraw_all(Editor_state)
-    Editor_state.screen_top1 = {line=1, pos=1}
-    Editor_state.cursor1 = {line=1, pos=1}
-    edit.fixup_cursor(Editor_state)
-  else
-    load_from_disk(Editor_state)
-    Text.redraw_all(Editor_state)
-    if Editor_state.cursor1.line > #Editor_state.lines or Editor_state.lines[Editor_state.cursor1.line].mode ~= 'text' then
-      edit.fixup_cursor(Editor_state)
-    end
+    Settings = json.decode(love.filesystem.read('config'))
+    Current_app = Settings.current_app
   end
-  love.window.setTitle('lines.love - '..Editor_state.filename)
 
-  if #arg > 1 then
-    print('ignoring commandline args after '..arg[1])
+  if Current_app == nil then
+    Current_app = 'run'
   end
 
-  if rawget(_G, 'jit') then
-    jit.off()
-    jit.flush()
+  if Current_app == 'run' then
+    load_file_from_source_or_save_directory('file.lua')
+    load_file_from_source_or_save_directory('run.lua')
+      load_file_from_source_or_save_directory('edit.lua')
+      load_file_from_source_or_save_directory('text.lua')
+        load_file_from_source_or_save_directory('search.lua')
+        load_file_from_source_or_save_directory('select.lua')
+        load_file_from_source_or_save_directory('undo.lua')
+      load_file_from_source_or_save_directory('icons.lua')
+      load_file_from_source_or_save_directory('text_tests.lua')
+    load_file_from_source_or_save_directory('run_tests.lua')
+    load_file_from_source_or_save_directory('drawing.lua')
+      load_file_from_source_or_save_directory('geom.lua')
+      load_file_from_source_or_save_directory('help.lua')
+    load_file_from_source_or_save_directory('drawing_tests.lua')
+  else
+    load_file_from_source_or_save_directory('source_file.lua')
+    load_file_from_source_or_save_directory('source.lua')
+      load_file_from_source_or_save_directory('commands.lua')
+      load_file_from_source_or_save_directory('source_edit.lua')
+      load_file_from_source_or_save_directory('log_browser.lua')
+      load_file_from_source_or_save_directory('source_text.lua')
+        load_file_from_source_or_save_directory('search.lua')
+        load_file_from_source_or_save_directory('select.lua')
+        load_file_from_source_or_save_directory('source_undo.lua')
+        load_file_from_source_or_save_directory('colorize.lua')
+      load_file_from_source_or_save_directory('source_text_tests.lua')
+    load_file_from_source_or_save_directory('source_tests.lua')
   end
 end
 
-function load_settings()
-  local settings = json.decode(love.filesystem.read('config'))
-  love.graphics.setFont(love.graphics.newFont(settings.font_height))
-  -- maximize window to determine maximum allowable dimensions
-  App.screen.width, App.screen.height, App.screen.flags = love.window.getMode()
-  -- set up desired window dimensions
-  love.window.setPosition(settings.x, settings.y, settings.displayindex)
-  App.screen.flags.resizable = true
-  App.screen.flags.minwidth = math.min(App.screen.width, 200)
-  App.screen.flags.minheight = math.min(App.screen.width, 200)
-  App.screen.width, App.screen.height = settings.width, settings.height
-  love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
-  Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right, settings.font_height, math.floor(settings.font_height*1.3))
-  Editor_state.filename = settings.filename
-  Editor_state.screen_top1 = settings.screen_top
-  Editor_state.cursor1 = settings.cursor
-end
+function App.initialize_globals()
+  if Current_app == 'run' then
+    run.initialize_globals()
+  elseif Current_app == 'source' then
+    source.initialize_globals()
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
 
-function initialize_default_settings()
-  local font_height = 20
-  love.graphics.setFont(love.graphics.newFont(font_height))
-  local em = App.newText(love.graphics.getFont(), 'm')
-  initialize_window_geometry(App.width(em))
-  Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right)
-  Editor_state.font_height = font_height
-  Editor_state.line_height = math.floor(font_height*1.3)
-  Editor_state.em = em
+  -- for hysteresis in a few places
+  Last_focus_time = App.getTime()  -- https://love2d.org/forums/viewtopic.php?p=249700
+  Last_resize_time = App.getTime()
 end
 
-function initialize_window_geometry(em_width)
-  -- maximize window
-  love.window.setMode(0, 0)  -- maximize
-  App.screen.width, App.screen.height, App.screen.flags = love.window.getMode()
-  -- shrink height slightly to account for window decoration
-  App.screen.height = App.screen.height-100
-  App.screen.width = 40*em_width
-  App.screen.flags.resizable = true
-  App.screen.flags.minwidth = math.min(App.screen.width, 200)
-  App.screen.flags.minheight = math.min(App.screen.width, 200)
-  love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
+function App.initialize(arg)
+  if Current_app == 'run' then
+    run.initialize(arg)
+  elseif Current_app == 'source' then
+    source.initialize(arg)
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
+  love.window.setTitle('text.love - '..Current_app)
 end
 
-function App.resize(w, h)
---?   print(("Window resized to width: %d and height: %d."):format(w, h))
-  App.screen.width, App.screen.height = w, h
-  Text.redraw_all(Editor_state)
-  Editor_state.selection1 = {}  -- no support for shift drag while we're resizing
-  Editor_state.right = App.screen.width-Margin_right
-  Editor_state.width = Editor_state.right-Editor_state.left
-  Text.tweak_screen_top_and_cursor(Editor_state, Editor_state.left, Editor_state.right)
+function App.resize(w,h)
+  if Current_app == 'run' then
+    if run.resize then run.resize(w,h) end
+  elseif Current_app == 'source' then
+    if source.resize then source.resize(w,h) end
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
   Last_resize_time = App.getTime()
 end
 
 function App.filedropped(file)
-  -- first make sure to save edits on any existing file
-  if Editor_state.next_save then
-    save_to_disk(Editor_state)
-  end
-  -- clear the slate for the new file
-  App.initialize_globals()
-  Editor_state.filename = file:getFilename()
-  file:open('r')
-  Editor_state.lines = load_from_file(file)
-  file:close()
-  Text.redraw_all(Editor_state)
-  edit.fixup_cursor(Editor_state)
-  love.window.setTitle('lines.love - '..Editor_state.filename)
+  if Current_app == 'run' then
+    if run.filedropped then run.filedropped(file) end
+  elseif Current_app == 'source' then
+    if source.filedropped then source.filedropped(file) end
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
+  love.window.setTitle('text.love - '..Current_app)
+end
+
+function App.focus(in_focus)
+  if in_focus then
+    Last_focus_time = App.getTime()
+  end
+  if Current_app == 'run' then
+    if run.focus then run.focus(in_focus) end
+  elseif Current_app == 'source' then
+    if source.focus then source.focus(in_focus) end
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
 end
 
 function App.draw()
-  edit.draw(Editor_state)
+  if Current_app == 'run' then
+    run.draw()
+  elseif Current_app == 'source' then
+    source.draw()
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
 end
 
 function App.update(dt)
-  Cursor_time = Cursor_time + dt
   -- some hysteresis while resizing
   if App.getTime() < Last_resize_time + 0.1 then
     return
   end
-  edit.update(Editor_state, dt)
-end
-
-function love.quit()
-  edit.quit(Editor_state)
-  -- save some important settings
-  local x,y,displayindex = love.window.getPosition()
-  local filename = Editor_state.filename
-  if filename:sub(1,1) ~= '/' then
-    filename = love.filesystem.getWorkingDirectory()..'/'..filename  -- '/' should work even on Windows
-  end
-  local settings = {
-    x=x, y=y, displayindex=displayindex,
-    width=App.screen.width, height=App.screen.height,
-    font_height=Editor_state.font_height,
-    filename=filename,
-    screen_top=Editor_state.screen_top1, cursor=Editor_state.cursor1}
-  love.filesystem.write('config', json.encode(settings))
-end
-
-function App.mousepressed(x,y, mouse_button)
-  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
-  return edit.mouse_pressed(Editor_state, x,y, mouse_button)
-end
-
-function App.mousereleased(x,y, mouse_button)
-  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
-  return edit.mouse_released(Editor_state, x,y, mouse_button)
+  --
+  if Current_app == 'run' then
+    run.update(dt)
+  elseif Current_app == 'source' then
+    source.update(dt)
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
 end
 
-function App.focus(in_focus)
-  if in_focus then
-    Last_focus_time = App.getTime()
+function App.keychord_pressed(chord, key)
+  -- ignore events for some time after window in focus (mostly alt-tab)
+  if App.getTime() < Last_focus_time + 0.01 then
+    return
+  end
+  --
+  if chord == 'C-e' then
+    -- carefully save settings
+    if Current_app == 'run' then
+      local source_settings = Settings.source
+      Settings = run.settings()
+      Settings.source = source_settings
+      if run.quit then run.quit() end
+      Current_app = 'source'
+    elseif Current_app == 'source' then
+      Settings.source = source.settings()
+      if source.quit then source.quit() end
+      Current_app = 'run'
+    else
+      assert(false, 'unknown app "'..Current_app..'"')
+    end
+    Settings.current_app = Current_app
+    love.filesystem.write('config', json.encode(Settings))
+    -- reboot
+    load_file_from_source_or_save_directory('main.lua')
+    App.undo_initialize()
+    App.run_tests_and_initialize()
+    return
+  end
+  if Current_app == 'run' then
+    if run.keychord_pressed then run.keychord_pressed(chord, key) end
+  elseif Current_app == 'source' then
+    if source.keychord_pressed then source.keychord_pressed(chord, key) end
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
   end
 end
 
 function App.textinput(t)
-  -- ignore events for some time after window in focus
+  -- ignore events for some time after window in focus (mostly alt-tab)
   if App.getTime() < Last_focus_time + 0.01 then
     return
   end
-  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
-  return edit.textinput(Editor_state, t)
+  --
+  if Current_app == 'run' then
+    if run.textinput then run.textinput(t) end
+  elseif Current_app == 'source' then
+    if source.textinput then source.textinput(t) end
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
 end
 
-function App.keychord_pressed(chord, key)
-  -- ignore events for some time after window in focus
+function App.keyreleased(chord, key)
+  -- ignore events for some time after window in focus (mostly alt-tab)
   if App.getTime() < Last_focus_time + 0.01 then
     return
   end
-  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
-  return edit.keychord_pressed(Editor_state, chord, key)
+  --
+  if Current_app == 'run' then
+    if run.key_released then run.key_released(chord, key) end
+  elseif Current_app == 'source' then
+    if source.key_released then source.key_released(chord, key) end
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
 end
 
-function App.keyreleased(key, scancode)
-  -- ignore events for some time after window in focus
-  if App.getTime() < Last_focus_time + 0.01 then
-    return
+function App.mousepressed(x,y, mouse_button)
+--?   print('mouse press', x,y)
+  if Current_app == 'run' then
+    if run.mouse_pressed then run.mouse_pressed(x,y, mouse_button) end
+  elseif Current_app == 'source' then
+    if source.mouse_pressed then source.mouse_pressed(x,y, mouse_button) end
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
+  end
+end
+
+function App.mousereleased(x,y, mouse_button)
+  if Current_app == 'run' then
+    if run.mouse_released then run.mouse_released(x,y, mouse_button) end
+  elseif Current_app == 'source' then
+    if source.mouse_released then source.mouse_released(x,y, mouse_button) end
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
   end
-  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
-  return edit.key_released(Editor_state, key, scancode)
 end
 
--- use this sparingly
-function to_text(s)
-  if Text_cache[s] == nil then
-    Text_cache[s] = App.newText(love.graphics.getFont(), s)
+function love.quit()
+  if Current_app == 'run' then
+    local source_settings = Settings.source
+    Settings = run.settings()
+    Settings.source = source_settings
+  else
+    Settings.source = source.settings()
+  end
+  Settings.current_app = Current_app
+  love.filesystem.write('config', json.encode(Settings))
+  if Current_app == 'run' then
+    if run.quit then run.quit() end
+  elseif Current_app == 'source' then
+    if source.quit then source.quit() end
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
   end
-  return Text_cache[s]
 end
diff --git a/run.lua b/run.lua
new file mode 100644
index 0000000..bb64aa3
--- /dev/null
+++ b/run.lua
@@ -0,0 +1,182 @@
+run = {}
+
+Editor_state = {}
+
+-- called both in tests and real run
+function run.initialize_globals()
+  -- tests currently mostly clear their own state
+
+  -- a few text objects we can avoid recomputing unless the font changes
+  Text_cache = {}
+
+  -- blinking cursor
+  Cursor_time = 0
+end
+
+-- called only for real run
+function run.initialize(arg)
+  love.keyboard.setTextInput(true)  -- bring up keyboard on touch screen
+  love.keyboard.setKeyRepeat(true)
+
+  love.graphics.setBackgroundColor(1,1,1)
+
+  if Settings then
+    run.load_settings()
+  else
+    run.initialize_default_settings()
+  end
+
+  if #arg > 0 then
+    Editor_state.filename = arg[1]
+    load_from_disk(Editor_state)
+    Text.redraw_all(Editor_state)
+    Editor_state.screen_top1 = {line=1, pos=1}
+    Editor_state.cursor1 = {line=1, pos=1}
+    edit.fixup_cursor(Editor_state)
+  else
+    load_from_disk(Editor_state)
+    Text.redraw_all(Editor_state)
+    if Editor_state.cursor1.line > #Editor_state.lines or Editor_state.lines[Editor_state.cursor1.line].mode ~= 'text' then
+      edit.fixup_cursor(Editor_state)
+    end
+  end
+  love.window.setTitle('lines.love - '..Editor_state.filename)
+
+  if #arg > 1 then
+    print('ignoring commandline args after '..arg[1])
+  end
+
+  if rawget(_G, 'jit') then
+    jit.off()
+    jit.flush()
+  end
+end
+
+function run.load_settings()
+  love.graphics.setFont(love.graphics.newFont(Settings.font_height))
+  -- maximize window to determine maximum allowable dimensions
+  App.screen.width, App.screen.height, App.screen.flags = love.window.getMode()
+  -- set up desired window dimensions
+  love.window.setPosition(Settings.x, Settings.y, Settings.displayindex)
+  App.screen.flags.resizable = true
+  App.screen.flags.minwidth = math.min(App.screen.width, 200)
+  App.screen.flags.minheight = math.min(App.screen.width, 200)
+  App.screen.width, App.screen.height = Settings.width, Settings.height
+  love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
+  Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right, Settings.font_height, math.floor(Settings.font_height*1.3))
+  Editor_state.filename = Settings.filename
+  Editor_state.screen_top1 = Settings.screen_top
+  Editor_state.cursor1 = Settings.cursor
+end
+
+function run.initialize_default_settings()
+  local font_height = 20
+  love.graphics.setFont(love.graphics.newFont(font_height))
+  local em = App.newText(love.graphics.getFont(), 'm')
+  run.initialize_window_geometry(App.width(em))
+  Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right)
+  Editor_state.font_height = font_height
+  Editor_state.line_height = math.floor(font_height*1.3)
+  Editor_state.em = em
+  Settings = run.settings()
+end
+
+function run.initialize_window_geometry(em_width)
+  -- maximize window
+  love.window.setMode(0, 0)  -- maximize
+  App.screen.width, App.screen.height, App.screen.flags = love.window.getMode()
+  -- shrink height slightly to account for window decoration
+  App.screen.height = App.screen.height-100
+  App.screen.width = 40*em_width
+  App.screen.flags.resizable = true
+  App.screen.flags.minwidth = math.min(App.screen.width, 200)
+  App.screen.flags.minheight = math.min(App.screen.width, 200)
+  love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
+end
+
+function run.resize(w, h)
+--?   print(("Window resized to width: %d and height: %d."):format(w, h))
+  App.screen.width, App.screen.height = w, h
+  Text.redraw_all(Editor_state)
+  Editor_state.selection1 = {}  -- no support for shift drag while we're resizing
+  Editor_state.right = App.screen.width-Margin_right
+  Editor_state.width = Editor_state.right-Editor_state.left
+  Text.tweak_screen_top_and_cursor(Editor_state, Editor_state.left, Editor_state.right)
+end
+
+function run.filedropped(file)
+  -- first make sure to save edits on any existing file
+  if Editor_state.next_save then
+    save_to_disk(Editor_state)
+  end
+  -- clear the slate for the new file
+  App.initialize_globals()
+  Editor_state.filename = file:getFilename()
+  file:open('r')
+  Editor_state.lines = load_from_file(file)
+  file:close()
+  Text.redraw_all(Editor_state)
+  edit.fixup_cursor(Editor_state)
+  love.window.setTitle('lines.love - '..Editor_state.filename)
+end
+
+function run.draw()
+  edit.draw(Editor_state)
+end
+
+function run.update(dt)
+  Cursor_time = Cursor_time + dt
+  edit.update(Editor_state, dt)
+end
+
+function run.quit()
+  edit.quit(Editor_state)
+end
+
+function run.settings()
+  local x,y,displayindex = love.window.getPosition()
+  local filename = Editor_state.filename
+  if filename:sub(1,1) ~= '/' then
+    filename = love.filesystem.getWorkingDirectory()..'/'..filename  -- '/' should work even on Windows
+  end
+  return {
+    x=x, y=y, displayindex=displayindex,
+    width=App.screen.width, height=App.screen.height,
+    font_height=Editor_state.font_height,
+    filename=filename,
+    screen_top=Editor_state.screen_top1, cursor=Editor_state.cursor1
+  }
+end
+
+function run.mouse_pressed(x,y, mouse_button)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+  return edit.mouse_pressed(Editor_state, x,y, mouse_button)
+end
+
+function run.mouse_released(x,y, mouse_button)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+  return edit.mouse_released(Editor_state, x,y, mouse_button)
+end
+
+function run.textinput(t)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+  return edit.textinput(Editor_state, t)
+end
+
+function run.keychord_pressed(chord, key)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+  return edit.keychord_pressed(Editor_state, chord, key)
+end
+
+function run.key_released(key, scancode)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+  return edit.key_released(Editor_state, key, scancode)
+end
+
+-- use this sparingly
+function to_text(s)
+  if Text_cache[s] == nil then
+    Text_cache[s] = App.newText(love.graphics.getFont(), s)
+  end
+  return Text_cache[s]
+end
diff --git a/main_tests.lua b/run_tests.lua
index 31605f0..31605f0 100644
--- a/main_tests.lua
+++ b/run_tests.lua
diff --git a/search.lua b/search.lua
index bd28d58..83545c9 100644
--- a/search.lua
+++ b/search.lua
@@ -30,8 +30,7 @@ function Text.search_next(State)
     for i=State.cursor1.line+1,#State.lines do
       pos = find(State.lines[i].data, State.search_term)
       if pos then
-        State.cursor1.line = i
-        State.cursor1.pos = pos
+        State.cursor1 = {line=i, pos=pos}
         break
       end
     end
@@ -41,8 +40,7 @@ function Text.search_next(State)
     for i=1,State.cursor1.line-1 do
       pos = find(State.lines[i].data, State.search_term)
       if pos then
-        State.cursor1.line = i
-        State.cursor1.pos = pos
+        State.cursor1 = {line=i, pos=pos}
         break
       end
     end
@@ -78,8 +76,7 @@ function Text.search_previous(State)
     for i=State.cursor1.line-1,1,-1 do
       pos = rfind(State.lines[i].data, State.search_term)
       if pos then
-        State.cursor1.line = i
-        State.cursor1.pos = pos
+        State.cursor1 = {line=i, pos=pos}
         break
       end
     end
@@ -89,8 +86,7 @@ function Text.search_previous(State)
     for i=#State.lines,State.cursor1.line+1,-1 do
       pos = rfind(State.lines[i].data, State.search_term)
       if pos then
-        State.cursor1.line = i
-        State.cursor1.pos = pos
+        State.cursor1 = {line=i, pos=pos}
         break
       end
     end
@@ -115,18 +111,18 @@ function Text.search_previous(State)
   end
 end
 
-function find(s, pat, i)
+function find(s, pat, i, plain)
   if s == nil then return end
-  return s:find(pat, i)
+  return s:find(pat, i, plain)
 end
 
-function rfind(s, pat, i)
+function rfind(s, pat, i, plain)
   if s == nil then return end
   local rs = s:reverse()
   local rpat = pat:reverse()
   if i == nil then i = #s end
   local ri = #s - i + 1
-  local rendpos = rs:find(rpat, ri)
+  local rendpos = rs:find(rpat, ri, plain)
   if rendpos == nil then return nil end
   local endpos = #s - rendpos + 1
   assert (endpos >= #pat)
diff --git a/source.lua b/source.lua
new file mode 100644
index 0000000..6f2b131
--- /dev/null
+++ b/source.lua
@@ -0,0 +1,358 @@
+source = {}
+
+Editor_state = {}
+
+-- called both in tests and real run
+function source.initialize_globals()
+  -- tests currently mostly clear their own state
+
+  Show_log_browser_side = false
+  Focus = 'edit'
+  Show_file_navigator = false
+  File_navigation = {
+    candidates = {
+      'run',
+      'run_tests',
+      'log',
+      'edit',
+      'text',
+      'search',
+      'select',
+      'undo',
+      'text_tests',
+      'file',
+      'source',
+      'source_tests',
+      'commands',
+      'log_browser',
+      'source_edit',
+      'source_text',
+      'source_undo',
+      'colorize',
+      'source_text_tests',
+      'source_file',
+      'main',
+      'button',
+      'keychord',
+      'app',
+      'test',
+      'json',
+    },
+    index = 1,
+  }
+
+  Menu_status_bar_height = nil  -- initialized below
+
+  -- a few text objects we can avoid recomputing unless the font changes
+  Text_cache = {}
+
+  -- blinking cursor
+  Cursor_time = 0
+end
+
+-- called only for real run
+function source.initialize()
+  love.keyboard.setTextInput(true)  -- bring up keyboard on touch screen
+  love.keyboard.setKeyRepeat(true)
+
+  love.graphics.setBackgroundColor(1,1,1)
+
+  if Settings and Settings.source then
+    source.load_settings()
+  else
+    source.initialize_default_settings()
+  end
+
+  source.initialize_edit_side{'run.lua'}
+  source.initialize_log_browser_side()
+
+  Menu_status_bar_height = 5 + Editor_state.line_height + 5
+  Editor_state.top = Editor_state.top + Menu_status_bar_height
+  Log_browser_state.top = Log_browser_state.top + Menu_status_bar_height
+end
+
+-- environment for a mutable file of bifolded text
+-- TODO: some initialization is also happening in load_settings/initialize_default_settings. Clean that up.
+function source.initialize_edit_side(arg)
+  if #arg > 0 then
+    Editor_state.filename = arg[1]
+    load_from_disk(Editor_state)
+    Text.redraw_all(Editor_state)
+    Editor_state.screen_top1 = {line=1, pos=1}
+    Editor_state.cursor1 = {line=1, pos=1}
+  else
+    load_from_disk(Editor_state)
+    Text.redraw_all(Editor_state)
+  end
+
+  if #arg > 1 then
+    print('ignoring commandline args after '..arg[1])
+  end
+
+  -- We currently start out with side B collapsed.
+  -- Other options:
+  --  * save all expanded state by line
+  --  * expand all if any location is in side B
+  if Editor_state.cursor1.line > #Editor_state.lines then
+    Editor_state.cursor1 = {line=1, pos=1}
+  end
+  if Editor_state.screen_top1.line > #Editor_state.lines then
+    Editor_state.screen_top1 = {line=1, pos=1}
+  end
+  edit.eradicate_locations_after_the_fold(Editor_state)
+
+  if rawget(_G, 'jit') then
+    jit.off()
+    jit.flush()
+  end
+end
+
+function source.load_settings()
+  local settings = Settings.source
+  love.graphics.setFont(love.graphics.newFont(settings.font_height))
+  -- maximize window to determine maximum allowable dimensions
+  love.window.setMode(0, 0)  -- maximize
+  Display_width, Display_height, App.screen.flags = love.window.getMode()
+  -- set up desired window dimensions
+  App.screen.flags.resizable = true
+  App.screen.flags.minwidth = math.min(Display_width, 200)
+  App.screen.flags.minheight = math.min(Display_height, 200)
+  App.screen.width, App.screen.height = settings.width, settings.height
+--?   print('setting window from settings:', App.screen.width, App.screen.height)
+  love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
+--?   print('loading source position', settings.x, settings.y, settings.displayindex)
+  source.set_window_position_from_settings(settings)
+  Show_log_browser_side = settings.show_log_browser_side
+  local right = App.screen.width - Margin_right
+  if Show_log_browser_side then
+    right = App.screen.width/2 - Margin_right
+  end
+  Editor_state = edit.initialize_state(Margin_top, Margin_left, right, settings.font_height, math.floor(settings.font_height*1.3))
+  Editor_state.filename = settings.filename
+  Editor_state.screen_top1 = settings.screen_top
+  Editor_state.cursor1 = settings.cursor
+end
+
+function source.set_window_position_from_settings(settings)
+  -- setPosition doesn't quite seem to do what is asked of it on Linux.
+  love.window.setPosition(settings.x, settings.y-37, settings.displayindex)
+end
+
+function source.initialize_default_settings()
+  local font_height = 20
+  love.graphics.setFont(love.graphics.newFont(font_height))
+  local em = App.newText(love.graphics.getFont(), 'm')
+  source.initialize_window_geometry(App.width(em))
+  Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right)
+  Editor_state.font_height = font_height
+  Editor_state.line_height = math.floor(font_height*1.3)
+  Editor_state.em = em
+end
+
+function source.initialize_window_geometry(em_width)
+  -- maximize window
+  love.window.setMode(0, 0)  -- maximize
+  Display_width, Display_height, App.screen.flags = love.window.getMode()
+  -- shrink height slightly to account for window decoration
+  App.screen.height = Display_height-100
+  App.screen.width = 40*em_width
+  App.screen.flags.resizable = true
+  App.screen.flags.minwidth = math.min(App.screen.width, 200)
+  App.screen.flags.minheight = math.min(App.screen.width, 200)
+  love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
+  print('initializing source position')
+  if Settings == nil then Settings = {} end
+  if Settings.source == nil then Settings.source = {} end
+  Settings.source.x, Settings.source.y, Settings.source.displayindex = love.window.getPosition()
+end
+
+function source.resize(w, h)
+--?   print(("Window resized to width: %d and height: %d."):format(w, h))
+  App.screen.width, App.screen.height = w, h
+  Text.redraw_all(Editor_state)
+  Editor_state.selection1 = {}  -- no support for shift drag while we're resizing
+  if Show_log_browser_side then
+    Editor_state.right = App.screen.width/2 - Margin_right
+  else
+    Editor_state.right = App.screen.width-Margin_right
+  end
+  Log_browser_state.left = App.screen.width/2 + Margin_right
+  Log_browser_state.right = App.screen.width-Margin_right
+  Editor_state.width = Editor_state.right-Editor_state.left
+  Text.tweak_screen_top_and_cursor(Editor_state, Editor_state.left, Editor_state.right)
+--?   print('end resize')
+end
+
+function source.filedropped(file)
+  -- first make sure to save edits on any existing file
+  if Editor_state.next_save then
+    save_to_disk(Editor_state)
+  end
+  -- clear the slate for the new file
+  Editor_state.filename = file:getFilename()
+  file:open('r')
+  Editor_state.lines = load_from_file(file)
+  file:close()
+  Text.redraw_all(Editor_state)
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.cursor1 = {line=1, pos=1}
+end
+
+-- a copy of source.filedropped when given a filename
+function source.switch_to_file(filename)
+  -- first make sure to save edits on any existing file
+  if Editor_state.next_save then
+    save_to_disk(Editor_state)
+  end
+  -- clear the slate for the new file
+  Editor_state.filename = filename
+  load_from_disk(Editor_state)
+  Text.redraw_all(Editor_state)
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.cursor1 = {line=1, pos=1}
+end
+
+function source.draw()
+  source.draw_menu_bar()
+  edit.draw(Editor_state)
+  if Show_log_browser_side then
+    -- divider
+    App.color(Divider_color)
+    love.graphics.rectangle('fill', App.screen.width/2-1,Menu_status_bar_height, 3,App.screen.height)
+    --
+    log_browser.draw(Log_browser_state)
+  end
+end
+
+function source.update(dt)
+  Cursor_time = Cursor_time + dt
+  if App.mouse_x() < Editor_state.right then
+    edit.update(Editor_state, dt)
+  elseif Show_log_browser_side then
+    log_browser.update(Log_browser_state, dt)
+  end
+end
+
+function source.quit()
+  edit.quit(Editor_state)
+  log_browser.quit(Log_browser_state)
+  -- convert any bifold files here
+end
+
+function source.convert_bifold_text(infilename, outfilename)
+  local contents = love.filesystem.read(infilename)
+  contents = contents:gsub('\u{1e}', ';')
+  love.filesystem.write(outfilename, contents)
+end
+
+function source.settings()
+  if Current_app == 'source' then
+--?     print('reading source window position')
+    Settings.source.x, Settings.source.y, Settings.source.displayindex = love.window.getPosition()
+  end
+  local filename = Editor_state.filename
+  if filename:sub(1,1) ~= '/' then
+    filename = love.filesystem.getWorkingDirectory()..'/'..filename  -- '/' should work even on Windows
+  end
+--?   print('saving source settings', Settings.source.x, Settings.source.y, Settings.source.displayindex)
+  return {
+    x=Settings.source.x, y=Settings.source.y, displayindex=Settings.source.displayindex,
+    width=App.screen.width, height=App.screen.height,
+    font_height=Editor_state.font_height,
+    filename=filename,
+    screen_top=Editor_state.screen_top1, cursor=Editor_state.cursor1,
+    show_log_browser_side=Show_log_browser_side,
+    focus=Focus,
+  }
+end
+
+function source.mouse_pressed(x,y, mouse_button)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+--?   print('mouse click', x, y)
+--?   print(Editor_state.left, Editor_state.right)
+--?   print(Log_browser_state.left, Log_browser_state.right)
+  if Editor_state.left <= x and x < Editor_state.right then
+--?     print('click on edit side')
+    if Focus ~= 'edit' then
+      Focus = 'edit'
+    end
+    edit.mouse_pressed(Editor_state, x,y, mouse_button)
+  elseif Show_log_browser_side and Log_browser_state.left <= x and x < Log_browser_state.right then
+--?     print('click on log_browser side')
+    if Focus ~= 'log_browser' then
+      Focus = 'log_browser'
+    end
+    log_browser.mouse_pressed(Log_browser_state, x,y, mouse_button)
+    for _,line_cache in ipairs(Editor_state.line_cache) do line_cache.starty = nil end  -- just in case we scroll
+  end
+end
+
+function source.mouse_released(x,y, mouse_button)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+  if Focus == 'edit' then
+    return edit.mouse_released(Editor_state, x,y, mouse_button)
+  else
+    return log_browser.mouse_released(Log_browser_state, x,y, mouse_button)
+  end
+end
+
+function source.textinput(t)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+  if Focus == 'edit' then
+    return edit.textinput(Editor_state, t)
+  else
+    return log_browser.textinput(Log_browser_state, t)
+  end
+end
+
+function source.keychord_pressed(chord, key)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+--?   print('source keychord')
+  if Show_file_navigator then
+    keychord_pressed_on_file_navigator(chord, key)
+    return
+  end
+  if chord == 'C-l' then
+--?     print('C-l')
+    Show_log_browser_side = not Show_log_browser_side
+    if Show_log_browser_side then
+      App.screen.width = Log_browser_state.right + Margin_right
+    else
+      App.screen.width = Editor_state.right + Margin_right
+    end
+--?     print('setting window:', App.screen.width, App.screen.height)
+    love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
+--?     print('done setting window')
+    -- try to restore position if possible
+    -- if the window gets wider the window manager may not respect this
+    source.set_window_position_from_settings(Settings.source)
+    return
+  end
+  if chord == 'C-g' then
+    Show_file_navigator = true
+    File_navigation.index = 1
+    return
+  end
+  if Focus == 'edit' then
+    return edit.keychord_pressed(Editor_state, chord, key)
+  else
+    return log_browser.keychord_pressed(Log_browser_state, chord, key)
+  end
+end
+
+function source.key_released(key, scancode)
+  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+  if Focus == 'edit' then
+    return edit.key_released(Editor_state, key, scancode)
+  else
+    return log_browser.keychord_pressed(Log_browser_state, chordkey, scancode)
+  end
+end
+
+-- use this sparingly
+function to_text(s)
+  if Text_cache[s] == nil then
+    Text_cache[s] = App.newText(love.graphics.getFont(), s)
+  end
+  return Text_cache[s]
+end
diff --git a/source_edit.lua b/source_edit.lua
new file mode 100644
index 0000000..d454467
--- /dev/null
+++ b/source_edit.lua
@@ -0,0 +1,377 @@
+-- some constants people might like to tweak
+Text_color = {r=0, g=0, b=0}
+Cursor_color = {r=1, g=0, b=0}
+Focus_stroke_color = {r=1, g=0, b=0}  -- what mouse is hovering over
+Highlight_color = {r=0.7, g=0.7, b=0.9}  -- selected text
+Fold_color = {r=0, g=0.6, b=0}
+Fold_background_color = {r=0, g=0.7, b=0}
+
+Margin_top = 15
+Margin_left = 25
+Margin_right = 25
+
+edit = {}
+
+-- run in both tests and a real run
+function edit.initialize_state(top, left, right, font_height, line_height)  -- currently always draws to bottom of screen
+  local result = {
+    -- a line of bifold text consists of an A side and an optional B side, each of which is a string
+    -- expanded: whether to show B side
+    lines = {{data='', dataB=nil, expanded=nil}},  -- array of lines
+
+    -- Lines can be too long to fit on screen, in which case they _wrap_ into
+    -- multiple _screen lines_.
+
+    -- rendering wrapped text lines needs some additional short-lived data per line:
+    --   startpos, the index of data the line starts rendering from, can only be >1 for topmost line on screen
+    --   starty, the y coord in pixels the line starts rendering from
+    --   fragments: snippets of rendered love.graphics.Text, guaranteed to not straddle screen lines
+    --   screen_line_starting_pos: optional array of grapheme indices if it wraps over more than one screen line
+    line_cache = {},
+
+    -- Given wrapping, any potential location for the text cursor can be described in two ways:
+    -- * schema 1: As a combination of line index and position within a line (in utf8 codepoint units)
+    -- * schema 2: As a combination of line index, screen line index within the line, and a position within the screen line.
+    -- Positions (and screen line indexes) can be in either the A or the B side.
+    --
+    -- Most of the time we'll only persist positions in schema 1, translating to
+    -- schema 2 when that's convenient.
+    --
+    -- Make sure these coordinates are never aliased, so that changing one causes
+    -- action at a distance.
+    screen_top1 = {line=1, pos=1, posB=nil},  -- position of start of screen line at top of screen
+    cursor1 = {line=1, pos=1, posB=nil},  -- position of cursor
+    screen_bottom1 = {line=1, pos=1, posB=nil},  -- position of start of screen line at bottom of screen
+
+    -- cursor coordinates in pixels
+    cursor_x = 0,
+    cursor_y = 0,
+
+    font_height = font_height,
+    line_height = line_height,
+    em = App.newText(love.graphics.getFont(), 'm'),  -- widest possible character width
+
+    top = top,
+    left = left,
+    right = right,
+    width = right-left,
+
+    filename = love.filesystem.getUserDirectory()..'/lines.txt',
+    next_save = nil,
+
+    -- undo
+    history = {},
+    next_history = 1,
+
+    -- search
+    search_term = nil,
+    search_text = nil,
+    search_backup = nil,  -- stuff to restore when cancelling search
+  }
+  return result
+end  -- App.initialize_state
+
+function edit.draw(State)
+  State.button_handlers = {}
+  App.color(Text_color)
+  assert(#State.lines == #State.line_cache)
+  if not Text.le1(State.screen_top1, State.cursor1) then
+    print(State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.cursor1.posB)
+    assert(false)
+  end
+  State.cursor_x = nil
+  State.cursor_y = nil
+  local y = State.top
+--?   print('== draw')
+  for line_index = State.screen_top1.line,#State.lines do
+    local line = State.lines[line_index]
+--?     print('draw:', y, line_index, line)
+    if y + State.line_height > App.screen.height then break end
+    State.screen_bottom1 = {line=line_index, pos=nil, posB=nil}
+--?     print('text.draw', y, line_index)
+    local startpos, startposB = 1, nil
+    if line_index == State.screen_top1.line then
+      if State.screen_top1.pos then
+        startpos = State.screen_top1.pos
+      else
+        startpos, startposB = nil, State.screen_top1.posB
+      end
+    end
+    y, State.screen_bottom1.pos, State.screen_bottom1.posB = Text.draw(State, line_index, y, startpos, startposB)
+    y = y + State.line_height
+--?     print('=> y', y)
+  end
+  if State.search_term then
+    Text.draw_search_bar(State)
+  end
+end
+
+function edit.update(State, dt)
+  if State.next_save and State.next_save < App.getTime() then
+    save_to_disk(State)
+    State.next_save = nil
+  end
+end
+
+function schedule_save(State)
+  if State.next_save == nil then
+    State.next_save = App.getTime() + 3  -- short enough that you're likely to still remember what you did
+  end
+end
+
+function edit.quit(State)
+  -- make sure to save before quitting
+  if State.next_save then
+    save_to_disk(State)
+  end
+end
+
+function edit.mouse_pressed(State, x,y, mouse_button)
+  if State.search_term then return end
+--?   print('press', State.selection1.line, State.selection1.pos)
+  if mouse_press_consumed_by_any_button_handler(State, x,y, mouse_button) then
+    -- press on a button and it returned 'true' to short-circuit
+    return
+  end
+
+  for line_index,line in ipairs(State.lines) do
+    if Text.in_line(State, line_index, x,y) then
+      local pos,posB = Text.to_pos_on_line(State, line_index, x, y)
+--?       print(x,y, 'setting cursor:', line_index, pos, posB)
+      State.cursor1 = {line=line_index, pos=pos, posB=posB}
+      break
+    end
+  end
+end
+
+function edit.mouse_released(State, x,y, mouse_button)
+end
+
+function edit.textinput(State, t)
+  for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end  -- just in case we scroll
+  if State.search_term then
+    State.search_term = State.search_term..t
+    State.search_text = nil
+    Text.search_next(State)
+  else
+    Text.textinput(State, t)
+  end
+  schedule_save(State)
+end
+
+function edit.keychord_pressed(State, chord, key)
+  if State.search_term then
+    if chord == 'escape' then
+      State.search_term = nil
+      State.search_text = nil
+      State.cursor1 = State.search_backup.cursor
+      State.screen_top1 = State.search_backup.screen_top
+      State.search_backup = nil
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+    elseif chord == 'return' then
+      State.search_term = nil
+      State.search_text = nil
+      State.search_backup = nil
+    elseif chord == 'backspace' then
+      local len = utf8.len(State.search_term)
+      local byte_offset = Text.offset(State.search_term, len)
+      State.search_term = string.sub(State.search_term, 1, byte_offset-1)
+      State.search_text = nil
+    elseif chord == 'down' then
+      if State.cursor1.pos then
+        State.cursor1.pos = State.cursor1.pos+1
+      else
+        State.cursor1.posB = State.cursor1.posB+1
+      end
+      Text.search_next(State)
+    elseif chord == 'up' then
+      Text.search_previous(State)
+    end
+    return
+  elseif chord == 'C-f' then
+    State.search_term = ''
+    State.search_backup = {
+      cursor={line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB},
+      screen_top={line=State.screen_top1.line, pos=State.screen_top1.pos, posB=State.screen_top1.posB},
+    }
+    assert(State.search_text == nil)
+  -- bifold text
+  elseif chord == 'C-b' then
+    State.expanded = not State.expanded
+    Text.redraw_all(State)
+    if not State.expanded then
+      for _,line in ipairs(State.lines) do
+        line.expanded = nil
+      end
+      edit.eradicate_locations_after_the_fold(State)
+    end
+  elseif chord == 'C-d' then
+    if State.cursor1.posB == nil then
+      local before = snapshot(State, State.cursor1.line)
+      if State.lines[State.cursor1.line].dataB == nil then
+        State.lines[State.cursor1.line].dataB = ''
+      end
+      State.lines[State.cursor1.line].expanded = true
+      State.cursor1.pos = nil
+      State.cursor1.posB = 1
+      if Text.cursor_out_of_screen(State) then
+        Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
+      end
+      schedule_save(State)
+      record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+    end
+  -- zoom
+  elseif chord == 'C-=' then
+    edit.update_font_settings(State, State.font_height+2)
+    Text.redraw_all(State)
+  elseif chord == 'C--' then
+    edit.update_font_settings(State, State.font_height-2)
+    Text.redraw_all(State)
+  elseif chord == 'C-0' then
+    edit.update_font_settings(State, 20)
+    Text.redraw_all(State)
+  -- undo
+  elseif chord == 'C-z' then
+    for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end  -- just in case we scroll
+    local event = undo_event(State)
+    if event then
+      local src = event.before
+      State.screen_top1 = deepcopy(src.screen_top)
+      State.cursor1 = deepcopy(src.cursor)
+      patch(State.lines, event.after, event.before)
+      patch_placeholders(State.line_cache, event.after, event.before)
+      -- if we're scrolling, reclaim all fragments to avoid memory leaks
+      Text.redraw_all(State)
+      schedule_save(State)
+    end
+  elseif chord == 'C-y' then
+    for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end  -- just in case we scroll
+    local event = redo_event(State)
+    if event then
+      local src = event.after
+      State.screen_top1 = deepcopy(src.screen_top)
+      State.cursor1 = deepcopy(src.cursor)
+      patch(State.lines, event.before, event.after)
+      -- if we're scrolling, reclaim all fragments to avoid memory leaks
+      Text.redraw_all(State)
+      schedule_save(State)
+    end
+  -- clipboard
+  elseif chord == 'C-c' then
+    local s = Text.selection(State)
+    if s then
+      App.setClipboardText(s)
+    end
+  elseif chord == 'C-x' then
+    for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end  -- just in case we scroll
+    local s = Text.cut_selection(State, State.left, State.right)
+    if s then
+      App.setClipboardText(s)
+    end
+    schedule_save(State)
+  elseif chord == 'C-v' then
+    for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end  -- just in case we scroll
+    -- We don't have a good sense of when to scroll, so we'll be conservative
+    -- and sometimes scroll when we didn't quite need to.
+    local before_line = State.cursor1.line
+    local before = snapshot(State, before_line)
+    local clipboard_data = App.getClipboardText()
+    for _,code in utf8.codes(clipboard_data) do
+      local c = utf8.char(code)
+      if c == '\n' then
+        Text.insert_return(State)
+      else
+        Text.insert_at_cursor(State, c)
+      end
+    end
+    if Text.cursor_out_of_screen(State) then
+      Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
+    end
+    schedule_save(State)
+    record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
+  -- dispatch to text
+  else
+    for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end  -- just in case we scroll
+    Text.keychord_pressed(State, chord)
+  end
+end
+
+function edit.eradicate_locations_after_the_fold(State)
+  -- eradicate side B from any locations we track
+  if State.cursor1.posB then
+    State.cursor1.posB = nil
+    State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data)
+    State.cursor1.pos = Text.pos_at_start_of_screen_line(State, State.cursor1)
+  end
+  if State.screen_top1.posB then
+    State.screen_top1.posB = nil
+    State.screen_top1.pos = utf8.len(State.lines[State.screen_top1.line].data)
+    State.screen_top1.pos = Text.pos_at_start_of_screen_line(State, State.screen_top1)
+  end
+end
+
+function edit.key_released(State, key, scancode)
+end
+
+function edit.update_font_settings(State, font_height)
+  State.font_height = font_height
+  love.graphics.setFont(love.graphics.newFont(Editor_state.font_height))
+  State.line_height = math.floor(font_height*1.3)
+  State.em = App.newText(love.graphics.getFont(), 'm')
+  Text_cache = {}
+end
+
+--== some methods for tests
+
+Test_margin_left = 25
+
+function edit.initialize_test_state()
+  -- if you change these values, tests will start failing
+  return edit.initialize_state(
+      15,  -- top margin
+      Test_margin_left,
+      App.screen.width,  -- right margin = 0
+      14,  -- font height assuming default LÖVE font
+      15)  -- line height
+end
+
+-- all textinput events are also keypresses
+-- TODO: handle chords of multiple keys
+function edit.run_after_textinput(State, t)
+  edit.keychord_pressed(State, t)
+  edit.textinput(State, t)
+  edit.key_released(State, t)
+  App.screen.contents = {}
+  edit.draw(State)
+end
+
+-- not all keys are textinput
+function edit.run_after_keychord(State, chord)
+  edit.keychord_pressed(State, chord)
+  edit.key_released(State, chord)
+  App.screen.contents = {}
+  edit.draw(State)
+end
+
+function edit.run_after_mouse_click(State, x,y, mouse_button)
+  App.fake_mouse_press(x,y, mouse_button)
+  edit.mouse_pressed(State, x,y, mouse_button)
+  App.fake_mouse_release(x,y, mouse_button)
+  edit.mouse_released(State, x,y, mouse_button)
+  App.screen.contents = {}
+  edit.draw(State)
+end
+
+function edit.run_after_mouse_press(State, x,y, mouse_button)
+  App.fake_mouse_press(x,y, mouse_button)
+  edit.mouse_pressed(State, x,y, mouse_button)
+  App.screen.contents = {}
+  edit.draw(State)
+end
+
+function edit.run_after_mouse_release(State, x,y, mouse_button)
+  App.fake_mouse_release(x,y, mouse_button)
+  edit.mouse_released(State, x,y, mouse_button)
+  App.screen.contents = {}
+  edit.draw(State)
+end
diff --git a/source_file.lua b/source_file.lua
new file mode 100644
index 0000000..978e949
--- /dev/null
+++ b/source_file.lua
@@ -0,0 +1,89 @@
+-- primitives for saving to file and loading from file
+
+Fold = '\x1e'  -- ASCII RS (record separator)
+
+function file_exists(filename)
+  local infile = App.open_for_reading(filename)
+  if infile then
+    infile:close()
+    return true
+  else
+    return false
+  end
+end
+
+function load_from_disk(State)
+  local infile = App.open_for_reading(State.filename)
+  State.lines = load_from_file(infile)
+  if infile then infile:close() end
+end
+
+function load_from_file(infile)
+  local result = {}
+  if infile then
+    local infile_next_line = infile:lines()  -- works with both Lua files and LÖVE Files (https://www.love2d.org/wiki/File)
+    while true do
+      local line = infile_next_line()
+      if line == nil then break end
+      local line_info = {}
+      if line:find(Fold) then
+        _, _, line_info.data, line_info.dataB = line:find('([^'..Fold..']*)'..Fold..'([^'..Fold..']*)')
+      else
+        line_info.data = line
+      end
+      table.insert(result, line_info)
+    end
+  end
+  if #result == 0 then
+    table.insert(result, {data=''})
+  end
+  return result
+end
+
+function save_to_disk(State)
+  local outfile = App.open_for_writing(State.filename)
+  if outfile == nil then
+    error('failed to write to "'..State.filename..'"')
+  end
+  for _,line in ipairs(State.lines) do
+    outfile:write(line.data)
+    if line.dataB and #line.dataB > 0 then
+      outfile:write(Fold)
+      outfile:write(line.dataB)
+    end
+    outfile:write('\n')
+  end
+  outfile:close()
+end
+
+function file_exists(filename)
+  local infile = App.open_for_reading(filename)
+  if infile then
+    infile:close()
+    return true
+  else
+    return false
+  end
+end
+
+-- for tests
+function load_array(a)
+  local result = {}
+  local next_line = ipairs(a)
+  local i,line,drawing = 0, ''
+  while true do
+    i,line = next_line(a, i)
+    if i == nil then break end
+    local line_info = {}
+    if line:find(Fold) then
+      _, _, line_info.data, line_info.dataB = line:find('([^'..Fold..']*)'..Fold..'([^'..Fold..']*)')
+    else
+      line_info.data = line
+    end
+    table.insert(result, line_info)
+  end
+  if #result == 0 then
+    table.insert(result, {data=''})
+  end
+  return result
+end
diff --git a/source_tests.lua b/source_tests.lua
new file mode 100644
index 0000000..dde4ec4
--- /dev/null
+++ b/source_tests.lua
@@ -0,0 +1,77 @@
+function test_resize_window()
+  io.write('\ntest_resize_window')
+  App.screen.init{width=300, height=300}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Log_browser_state = edit.initialize_test_state()
+  check_eq(App.screen.width, 300, 'F - test_resize_window/baseline/width')
+  check_eq(App.screen.height, 300, 'F - test_resize_window/baseline/height')
+  check_eq(Editor_state.left, Test_margin_left, 'F - test_resize_window/baseline/left_margin')
+  App.resize(200, 400)
+  check_eq(App.screen.width, 200, 'F - test_resize_window/width')
+  check_eq(App.screen.height, 400, 'F - test_resize_window/height')
+  check_eq(Editor_state.left, Test_margin_left, 'F - test_resize_window/left_margin')
+  -- ugly; right margin switches from 0 after resize
+  check_eq(Editor_state.right, 200-Margin_right, 'F - test_resize_window/right_margin')
+  check_eq(Editor_state.width, 200-Test_margin_left-Margin_right, 'F - test_resize_window/drawing_width')
+  -- TODO: how to make assertions about when App.update got past the early exit?
+end
+
+function test_drop_file()
+  io.write('\ntest_drop_file')
+  App.screen.init{width=Editor_state.left+300, height=300}
+  Editor_state = edit.initialize_test_state()
+  App.filesystem['foo'] = 'abc\ndef\nghi\n'
+  local fake_dropped_file = {
+    opened = false,
+    getFilename = function(self)
+                    return 'foo'
+                  end,
+    open = function(self)
+             self.opened = true
+           end,
+    lines = function(self)
+              assert(self.opened)
+              return App.filesystem['foo']:gmatch('[^\n]+')
+            end,
+    close = function(self)
+              self.opened = false
+            end,
+  }
+  App.filedropped(fake_dropped_file)
+  check_eq(#Editor_state.lines, 3, 'F - test_drop_file/#lines')
+  check_eq(Editor_state.lines[1].data, 'abc', 'F - test_drop_file/lines:1')
+  check_eq(Editor_state.lines[2].data, 'def', 'F - test_drop_file/lines:2')
+  check_eq(Editor_state.lines[3].data, 'ghi', 'F - test_drop_file/lines:3')
+  edit.draw(Editor_state)
+end
+
+function test_drop_file_saves_previous()
+  io.write('\ntest_drop_file_saves_previous')
+  App.screen.init{width=Editor_state.left+300, height=300}
+  -- initially editing a file called foo that hasn't been saved to filesystem yet
+  Editor_state.lines = load_array{'abc', 'def'}
+  Editor_state.filename = 'foo'
+  schedule_save(Editor_state)
+  -- now drag a new file bar from the filesystem
+  App.filesystem['bar'] = 'abc\ndef\nghi\n'
+  local fake_dropped_file = {
+    opened = false,
+    getFilename = function(self)
+                    return 'bar'
+                  end,
+    open = function(self)
+             self.opened = true
+           end,
+    lines = function(self)
+              assert(self.opened)
+              return App.filesystem['bar']:gmatch('[^\n]+')
+            end,
+    close = function(self)
+              self.opened = false
+            end,
+  }
+  App.filedropped(fake_dropped_file)
+  -- filesystem now contains a file called foo
+  check_eq(App.filesystem['foo'], 'abc\ndef\n', 'F - test_drop_file_saves_previous')
+end
diff --git a/source_text.lua b/source_text.lua
new file mode 100644
index 0000000..e491dac
--- /dev/null
+++ b/source_text.lua
@@ -0,0 +1,1561 @@
+-- text editor, particularly text drawing, horizontal wrap, vertical scrolling
+Text = {}
+AB_padding = 20  -- space in pixels between A side and B side
+
+-- draw a line starting from startpos to screen at y between State.left and State.right
+-- return the final y, and pos,posB of start of final screen line drawn
+function Text.draw(State, line_index, y, startpos, startposB)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  line_cache.starty = y
+  line_cache.startpos = startpos
+  line_cache.startposB = startposB
+  -- draw A side
+  local overflows_screen, x, pos, screen_line_starting_pos
+  if startpos then
+    overflows_screen, x, y, pos, screen_line_starting_pos = Text.draw_wrapping_line(State, line_index, State.left, y, startpos)
+    if overflows_screen then
+      return y, screen_line_starting_pos
+    end
+    if Focus == 'edit' and State.cursor1.pos then
+      if State.search_term == nil then
+        if line_index == State.cursor1.line and State.cursor1.pos == pos then
+          Text.draw_cursor(State, x, y)
+        end
+      end
+    end
+  else
+    x = State.left
+  end
+  -- check for B side
+--?   if line_index == 8 then print('checking for B side') end
+  if line.dataB == nil then
+    assert(y)
+    assert(screen_line_starting_pos)
+--?     if line_index == 8 then print('return 1') end
+    return y, screen_line_starting_pos
+  end
+  if not State.expanded and not line.expanded then
+    assert(y)
+    assert(screen_line_starting_pos)
+--?     if line_index == 8 then print('return 2') end
+    button(State, 'expand', {x=x+AB_padding, y=y+2, w=App.width(State.em), h=State.line_height-4, color={1,1,1},
+      icon = function(button_params)
+               App.color(Fold_background_color)
+               love.graphics.rectangle('fill', button_params.x, button_params.y, App.width(State.em), State.line_height-4, 2,2)
+             end,
+      onpress1 = function()
+                   line.expanded = true
+                 end,
+    })
+    return y, screen_line_starting_pos
+  end
+  -- draw B side
+--?   if line_index == 8 then print('drawing B side') end
+  App.color(Fold_color)
+--?   if Foo then
+--?     print('draw:', State.lines[line_index].data, "=====", State.lines[line_index].dataB, 'starting from x', x+AB_padding)
+--?   end
+  if startposB then
+    overflows_screen, x, y, pos, screen_line_starting_pos = Text.draw_wrapping_lineB(State, line_index, x,y, startposB)
+  else
+    overflows_screen, x, y, pos, screen_line_starting_pos = Text.draw_wrapping_lineB(State, line_index, x+AB_padding,y, 1)
+  end
+  if overflows_screen then
+    return y, nil, screen_line_starting_pos
+  end
+--?   if line_index == 8 then print('a') end
+  if Focus == 'edit' and State.cursor1.posB then
+--?     if line_index == 8 then print('b') end
+    if State.search_term == nil then
+--?       if line_index == 8 then print('c', State.cursor1.line, State.cursor1.posB, line_index, pos) end
+      if line_index == State.cursor1.line and State.cursor1.posB == pos then
+        Text.draw_cursor(State, x, y)
+      end
+    end
+  end
+  return y, nil, screen_line_starting_pos
+end
+
+-- Given an array of fragments, draw the subset starting from pos to screen
+-- starting from (x,y).
+-- Return:
+--  - whether we got to bottom of screen before end of line
+--  - the final (x,y)
+--  - the final pos
+--  - starting pos of the final screen line drawn
+function Text.draw_wrapping_line(State, line_index, x,y, startpos)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+--?   print('== line', line_index, '^'..line.data..'$')
+  local screen_line_starting_pos = startpos
+  Text.compute_fragments(State, line_index)
+  local pos = 1
+  initialize_color()
+  for _, f in ipairs(line_cache.fragments) do
+    App.color(Text_color)
+    local frag, frag_text = f.data, f.text
+    select_color(frag)
+    local frag_len = utf8.len(frag)
+--?     print('text.draw:', frag, 'at', line_index,pos, 'after', x,y)
+    if pos < startpos then
+      -- render nothing
+--?       print('skipping', frag)
+    else
+      -- render fragment
+      local frag_width = App.width(frag_text)
+      if x + frag_width > State.right then
+        assert(x > State.left)  -- no overfull lines
+        y = y + State.line_height
+        if y + State.line_height > App.screen.height then
+          return --[[screen filled]] true, x,y, pos, screen_line_starting_pos
+        end
+        screen_line_starting_pos = pos
+        x = State.left
+      end
+      App.screen.draw(frag_text, x,y)
+      -- render cursor if necessary
+      if State.cursor1.pos and line_index == State.cursor1.line then
+        if pos <= State.cursor1.pos and pos + frag_len > State.cursor1.pos then
+          if State.search_term then
+            if State.lines[State.cursor1.line].data:sub(State.cursor1.pos, State.cursor1.pos+utf8.len(State.search_term)-1) == State.search_term then
+              local lo_px = Text.draw_highlight(State, line, x,y, pos, State.cursor1.pos, State.cursor1.pos+utf8.len(State.search_term))
+              App.color(Text_color)
+              love.graphics.print(State.search_term, x+lo_px,y)
+            end
+          elseif Focus == 'edit' then
+            Text.draw_cursor(State, x+Text.x(frag, State.cursor1.pos-pos+1), y)
+            App.color(Text_color)
+          end
+        end
+      end
+      x = x + frag_width
+    end
+    pos = pos + frag_len
+  end
+  return false, x,y, pos, screen_line_starting_pos
+end
+
+function Text.draw_wrapping_lineB(State, line_index, x,y, startpos)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  local screen_line_starting_pos = startpos
+  Text.compute_fragmentsB(State, line_index, x)
+  local pos = 1
+  for _, f in ipairs(line_cache.fragmentsB) do
+    local frag, frag_text = f.data, f.text
+    local frag_len = utf8.len(frag)
+--?     print('text.draw:', frag, 'at', line_index,pos, 'after', x,y)
+    if pos < startpos then
+      -- render nothing
+--?       print('skipping', frag)
+    else
+      -- render fragment
+      local frag_width = App.width(frag_text)
+      if x + frag_width > State.right then
+        assert(x > State.left)  -- no overfull lines
+        y = y + State.line_height
+        if y + State.line_height > App.screen.height then
+          return --[[screen filled]] true, x,y, pos, screen_line_starting_pos
+        end
+        screen_line_starting_pos = pos
+        x = State.left
+      end
+      App.screen.draw(frag_text, x,y)
+      -- render cursor if necessary
+      if State.cursor1.posB and line_index == State.cursor1.line then
+        if pos <= State.cursor1.posB and pos + frag_len > State.cursor1.posB then
+          if State.search_term then
+            if State.lines[State.cursor1.line].dataB:sub(State.cursor1.posB, State.cursor1.posB+utf8.len(State.search_term)-1) == State.search_term then
+              local lo_px = Text.draw_highlight(State, line, x,y, pos, State.cursor1.posB, State.cursor1.posB+utf8.len(State.search_term))
+              App.color(Fold_color)
+              love.graphics.print(State.search_term, x+lo_px,y)
+            end
+          elseif Focus == 'edit' then
+            Text.draw_cursor(State, x+Text.x(frag, State.cursor1.posB-pos+1), y)
+            App.color(Fold_color)
+          end
+        end
+      end
+      x = x + frag_width
+    end
+    pos = pos + frag_len
+  end
+  return false, x,y, pos, screen_line_starting_pos
+end
+
+function Text.draw_cursor(State, x, y)
+  -- blink every 0.5s
+  if math.floor(Cursor_time*2)%2 == 0 then
+    App.color(Cursor_color)
+    love.graphics.rectangle('fill', x,y, 3,State.line_height)
+  end
+  State.cursor_x = x
+  State.cursor_y = y+State.line_height
+end
+
+function Text.populate_screen_line_starting_pos(State, line_index)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  if line_cache.screen_line_starting_pos then
+    return
+  end
+  -- duplicate some logic from Text.draw
+  Text.compute_fragments(State, line_index)
+  line_cache.screen_line_starting_pos = {1}
+  local x = State.left
+  local pos = 1
+  for _, f in ipairs(line_cache.fragments) do
+    local frag, frag_text = f.data, f.text
+    -- render fragment
+    local frag_width = App.width(frag_text)
+    if x + frag_width > State.right then
+      x = State.left
+      table.insert(line_cache.screen_line_starting_pos, pos)
+    end
+    x = x + frag_width
+    local frag_len = utf8.len(frag)
+    pos = pos + frag_len
+  end
+end
+
+function Text.compute_fragments(State, line_index)
+--?   print('compute_fragments', line_index, 'between', State.left, State.right)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  if line_cache.fragments then
+    return
+  end
+  line_cache.fragments = {}
+  local x = State.left
+  -- try to wrap at word boundaries
+  for frag in line.data:gmatch('%S*%s*') do
+    local frag_text = App.newText(love.graphics.getFont(), frag)
+    local frag_width = App.width(frag_text)
+--?     print('x: '..tostring(x)..'; frag_width: '..tostring(frag_width)..'; '..tostring(State.right-x)..'px to go')
+    while x + frag_width > State.right do
+--?       print(('checking whether to split fragment ^%s$ of width %d when rendering from %d'):format(frag, frag_width, x))
+      if (x-State.left) < 0.8 * (State.right-State.left) then
+--?         print('splitting')
+        -- long word; chop it at some letter
+        -- We're not going to reimplement TeX here.
+        local bpos = Text.nearest_pos_less_than(frag, State.right - x)
+--?         print('bpos', bpos)
+        if bpos == 0 then break end  -- avoid infinite loop when window is too narrow
+        local boffset = Text.offset(frag, bpos+1)  -- byte _after_ bpos
+--?         print('space for '..tostring(bpos)..' graphemes, '..tostring(boffset-1)..' bytes')
+        local frag1 = string.sub(frag, 1, boffset-1)
+        local frag1_text = App.newText(love.graphics.getFont(), frag1)
+        local frag1_width = App.width(frag1_text)
+--?         print('extracting ^'..frag1..'$ of width '..tostring(frag1_width)..'px')
+        assert(x + frag1_width <= State.right)
+        table.insert(line_cache.fragments, {data=frag1, text=frag1_text})
+        frag = string.sub(frag, boffset)
+        frag_text = App.newText(love.graphics.getFont(), frag)
+        frag_width = App.width(frag_text)
+      end
+      x = State.left  -- new line
+    end
+    if #frag > 0 then
+--?       print('inserting ^'..frag..'$ of width '..tostring(frag_width)..'px')
+      table.insert(line_cache.fragments, {data=frag, text=frag_text})
+    end
+    x = x + frag_width
+  end
+end
+
+function Text.populate_screen_line_starting_posB(State, line_index, x)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  if line_cache.screen_line_starting_posB then
+    return
+  end
+  -- duplicate some logic from Text.draw
+  Text.compute_fragmentsB(State, line_index, x)
+  line_cache.screen_line_starting_posB = {1}
+  local pos = 1
+  for _, f in ipairs(line_cache.fragmentsB) do
+    local frag, frag_text = f.data, f.text
+    -- render fragment
+    local frag_width = App.width(frag_text)
+    if x + frag_width > State.right then
+      x = State.left
+      table.insert(line_cache.screen_line_starting_posB, pos)
+    end
+    x = x + frag_width
+    local frag_len = utf8.len(frag)
+    pos = pos + frag_len
+  end
+end
+
+function Text.compute_fragmentsB(State, line_index, x)
+--?   print('compute_fragmentsB', line_index, 'between', x, State.right)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  if line_cache.fragmentsB then
+    return
+  end
+  line_cache.fragmentsB = {}
+  -- try to wrap at word boundaries
+  for frag in line.dataB:gmatch('%S*%s*') do
+    local frag_text = App.newText(love.graphics.getFont(), frag)
+    local frag_width = App.width(frag_text)
+--?     print('x: '..tostring(x)..'; '..tostring(State.right-x)..'px to go')
+    while x + frag_width > State.right do
+--?       print(('checking whether to split fragment ^%s$ of width %d when rendering from %d'):format(frag, frag_width, x))
+      if (x-State.left) < 0.8 * (State.right-State.left) then
+--?         print('splitting')
+        -- long word; chop it at some letter
+        -- We're not going to reimplement TeX here.
+        local bpos = Text.nearest_pos_less_than(frag, State.right - x)
+--?         print('bpos', bpos)
+        if bpos == 0 then break end  -- avoid infinite loop when window is too narrow
+        local boffset = Text.offset(frag, bpos+1)  -- byte _after_ bpos
+--?         print('space for '..tostring(bpos)..' graphemes, '..tostring(boffset-1)..' bytes')
+        local frag1 = string.sub(frag, 1, boffset-1)
+        local frag1_text = App.newText(love.graphics.getFont(), frag1)
+        local frag1_width = App.width(frag1_text)
+--?         print('extracting ^'..frag1..'$ of width '..tostring(frag1_width)..'px')
+        assert(x + frag1_width <= State.right)
+        table.insert(line_cache.fragmentsB, {data=frag1, text=frag1_text})
+        frag = string.sub(frag, boffset)
+        frag_text = App.newText(love.graphics.getFont(), frag)
+        frag_width = App.width(frag_text)
+      end
+      x = State.left  -- new line
+    end
+    if #frag > 0 then
+--?       print('inserting ^'..frag..'$ of width '..tostring(frag_width)..'px')
+      table.insert(line_cache.fragmentsB, {data=frag, text=frag_text})
+    end
+    x = x + frag_width
+  end
+end
+
+function Text.textinput(State, t)
+  if App.mouse_down(1) then return end
+  if App.ctrl_down() or App.alt_down() or App.cmd_down() then return end
+  local before = snapshot(State, State.cursor1.line)
+--?   print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
+  Text.insert_at_cursor(State, t)
+  if State.cursor_y > App.screen.height - State.line_height then
+    Text.populate_screen_line_starting_pos(State, State.cursor1.line)
+    Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
+  end
+  record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+end
+
+function Text.insert_at_cursor(State, t)
+  if State.cursor1.pos then
+    local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
+    State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_offset-1)..t..string.sub(State.lines[State.cursor1.line].data, byte_offset)
+    Text.clear_screen_line_cache(State, State.cursor1.line)
+    State.cursor1.pos = State.cursor1.pos+1
+  else
+    assert(State.cursor1.posB)
+    local byte_offset = Text.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB)
+    State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_offset-1)..t..string.sub(State.lines[State.cursor1.line].dataB, byte_offset)
+    Text.clear_screen_line_cache(State, State.cursor1.line)
+    State.cursor1.posB = State.cursor1.posB+1
+  end
+end
+
+-- Don't handle any keys here that would trigger love.textinput above.
+function Text.keychord_pressed(State, chord)
+--?   print('chord', chord)
+  --== shortcuts that mutate text
+  if chord == 'return' then
+    local before_line = State.cursor1.line
+    local before = snapshot(State, before_line)
+    Text.insert_return(State)
+    if State.cursor_y > App.screen.height - State.line_height then
+      Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
+    end
+    schedule_save(State)
+    record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
+  elseif chord == 'tab' then
+    local before = snapshot(State, State.cursor1.line)
+--?     print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
+    Text.insert_at_cursor(State, '\t')
+    if State.cursor_y > App.screen.height - State.line_height then
+      Text.populate_screen_line_starting_pos(State, State.cursor1.line)
+      Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
+--?       print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
+    end
+    schedule_save(State)
+    record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+  elseif chord == 'backspace' then
+    local before
+    if State.cursor1.pos and State.cursor1.pos > 1 then
+      before = snapshot(State, State.cursor1.line)
+      local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos-1)
+      local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
+      if byte_start then
+        if byte_end then
+          State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].data, byte_end)
+        else
+          State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)
+        end
+        State.cursor1.pos = State.cursor1.pos-1
+      end
+    elseif State.cursor1.posB then
+      if State.cursor1.posB > 1 then
+        before = snapshot(State, State.cursor1.line)
+        local byte_start = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB-1)
+        local byte_end = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB)
+        if byte_start then
+          if byte_end then
+            State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].dataB, byte_end)
+          else
+            State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)
+          end
+          State.cursor1.posB = State.cursor1.posB-1
+        end
+      else
+        -- refuse to delete past beginning of side B
+      end
+    elseif State.cursor1.line > 1 then
+      before = snapshot(State, State.cursor1.line-1, State.cursor1.line)
+      -- join lines
+      State.cursor1.pos = utf8.len(State.lines[State.cursor1.line-1].data)+1
+      State.lines[State.cursor1.line-1].data = State.lines[State.cursor1.line-1].data..State.lines[State.cursor1.line].data
+      table.remove(State.lines, State.cursor1.line)
+      table.remove(State.line_cache, State.cursor1.line)
+      State.cursor1.line = State.cursor1.line-1
+    end
+    if State.screen_top1.line > #State.lines then
+      Text.populate_screen_line_starting_pos(State, #State.lines)
+      local line_cache = State.line_cache[#State.line_cache]
+      State.screen_top1 = {line=#State.lines, pos=line_cache.screen_line_starting_pos[#line_cache.screen_line_starting_pos]}
+    elseif Text.lt1(State.cursor1, State.screen_top1) then
+      local top2 = Text.to2(State, State.screen_top1)
+      top2 = Text.previous_screen_line(State, top2, State.left, State.right)
+      State.screen_top1 = Text.to1(State, top2)
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+    end
+    Text.clear_screen_line_cache(State, State.cursor1.line)
+    assert(Text.le1(State.screen_top1, State.cursor1))
+    schedule_save(State)
+    record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+  elseif chord == 'delete' then
+    local before
+    if State.cursor1.posB or State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
+      before = snapshot(State, State.cursor1.line)
+    else
+      before = snapshot(State, State.cursor1.line, State.cursor1.line+1)
+    end
+    if State.cursor1.pos and State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
+      local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
+      local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos+1)
+      if byte_start then
+        if byte_end then
+          State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].data, byte_end)
+        else
+          State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)
+        end
+        -- no change to State.cursor1.pos
+      end
+    elseif State.cursor1.posB then
+      if State.cursor1.posB <= utf8.len(State.lines[State.cursor1.line].dataB) then
+        local byte_start = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB)
+        local byte_end = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB+1)
+        if byte_start then
+          if byte_end then
+            State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].dataB, byte_end)
+          else
+            State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)
+          end
+          -- no change to State.cursor1.pos
+        end
+      else
+        -- refuse to delete past end of side B
+      end
+    elseif State.cursor1.line < #State.lines then
+      -- join lines
+      State.lines[State.cursor1.line].data = State.lines[State.cursor1.line].data..State.lines[State.cursor1.line+1].data
+      -- delete side B on first line
+      State.lines[State.cursor1.line].dataB = State.lines[State.cursor1.line+1].dataB
+      table.remove(State.lines, State.cursor1.line+1)
+      table.remove(State.line_cache, State.cursor1.line+1)
+    end
+    Text.clear_screen_line_cache(State, State.cursor1.line)
+    schedule_save(State)
+    record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+  --== shortcuts that move the cursor
+  elseif chord == 'left' then
+    Text.left(State)
+  elseif chord == 'right' then
+    Text.right(State)
+  elseif chord == 'S-left' then
+    Text.left(State)
+  elseif chord == 'S-right' then
+    Text.right(State)
+  -- C- hotkeys reserved for drawings, so we'll use M-
+  elseif chord == 'M-left' then
+    Text.word_left(State)
+  elseif chord == 'M-right' then
+    Text.word_right(State)
+  elseif chord == 'M-S-left' then
+    Text.word_left(State)
+  elseif chord == 'M-S-right' then
+    Text.word_right(State)
+  elseif chord == 'home' then
+    Text.start_of_line(State)
+  elseif chord == 'end' then
+    Text.end_of_line(State)
+  elseif chord == 'S-home' then
+    Text.start_of_line(State)
+  elseif chord == 'S-end' then
+    Text.end_of_line(State)
+  elseif chord == 'up' then
+    Text.up(State)
+  elseif chord == 'down' then
+    Text.down(State)
+  elseif chord == 'S-up' then
+    Text.up(State)
+  elseif chord == 'S-down' then
+    Text.down(State)
+  elseif chord == 'pageup' then
+    Text.pageup(State)
+  elseif chord == 'pagedown' then
+    Text.pagedown(State)
+  elseif chord == 'S-pageup' then
+    Text.pageup(State)
+  elseif chord == 'S-pagedown' then
+    Text.pagedown(State)
+  end
+end
+
+function Text.insert_return(State)
+  if State.cursor1.pos then
+    -- when inserting a newline, move any B side to the new line
+    local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
+    table.insert(State.lines, State.cursor1.line+1, {data=string.sub(State.lines[State.cursor1.line].data, byte_offset), dataB=State.lines[State.cursor1.line].dataB})
+    table.insert(State.line_cache, State.cursor1.line+1, {})
+    State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_offset-1)
+    State.lines[State.cursor1.line].dataB = nil
+    Text.clear_screen_line_cache(State, State.cursor1.line)
+    State.cursor1 = {line=State.cursor1.line+1, pos=1}
+  else
+    -- disable enter when cursor is on the B side
+  end
+end
+
+function Text.pageup(State)
+--?   print('pageup')
+  -- duplicate some logic from love.draw
+  local top2 = Text.to2(State, State.screen_top1)
+--?   print(App.screen.height)
+  local y = App.screen.height - State.line_height
+  while y >= State.top do
+--?     print(y, top2.line, top2.screen_line, top2.screen_pos)
+    if State.screen_top1.line == 1 and State.screen_top1.pos and State.screen_top1.pos == 1 then break end
+    y = y - State.line_height
+    top2 = Text.previous_screen_line(State, top2)
+  end
+  State.screen_top1 = Text.to1(State, top2)
+  State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos, posB=State.screen_top1.posB}
+  Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
+--?   print(State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
+--?   print('pageup end')
+end
+
+function Text.pagedown(State)
+--?   print('pagedown')
+  local bot2 = Text.to2(State, State.screen_bottom1)
+  local new_top1 = Text.to1(State, bot2)
+  if Text.lt1(State.screen_top1, new_top1) then
+    State.screen_top1 = new_top1
+  else
+    State.screen_top1 = {line=State.screen_bottom1.line, pos=State.screen_bottom1.pos}
+  end
+--?   print('setting top to', State.screen_top1.line, State.screen_top1.pos)
+  State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos, posB=State.screen_top1.posB}
+  Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
+--?   print('top now', State.screen_top1.line)
+  Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+--?   print('pagedown end')
+end
+
+function Text.up(State)
+  if State.cursor1.pos then
+    Text.upA(State)
+  else
+    Text.upB(State)
+  end
+end
+
+function Text.upA(State)
+--?   print('up', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
+  local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1)
+  if screen_line_starting_pos == 1 then
+--?     print('cursor is at first screen line of its line')
+    -- line is done; skip to previous text line
+    if State.cursor1.line > 1 then
+--?       print('found previous text line')
+      State.cursor1 = {line=State.cursor1.line-1, pos=nil}
+      Text.populate_screen_line_starting_pos(State, State.cursor1.line)
+      -- previous text line found, pick its final screen line
+--?       print('has multiple screen lines')
+      local screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos
+--?       print(#screen_line_starting_pos)
+      screen_line_starting_pos = screen_line_starting_pos[#screen_line_starting_pos]
+      local screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, screen_line_starting_pos)
+      local s = string.sub(State.lines[State.cursor1.line].data, screen_line_starting_byte_offset)
+      State.cursor1.pos = screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
+    end
+  else
+    -- move up one screen line in current line
+    assert(screen_line_index > 1)
+    local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index-1]
+    local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
+    local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
+    State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
+--?     print('cursor pos is now '..tostring(State.cursor1.pos))
+  end
+  if Text.lt1(State.cursor1, State.screen_top1) then
+    local top2 = Text.to2(State, State.screen_top1)
+    top2 = Text.previous_screen_line(State, top2)
+    State.screen_top1 = Text.to1(State, top2)
+  end
+end
+
+function Text.upB(State)
+  local line_cache = State.line_cache[State.cursor1.line]
+  local screen_line_starting_posB, screen_line_indexB = Text.pos_at_start_of_screen_lineB(State, State.cursor1)
+  assert(screen_line_indexB >= 1)
+  if screen_line_indexB == 1 then
+    -- move to A side of previous line
+    if State.cursor1.line > 1 then
+      State.cursor1.line = State.cursor1.line-1
+      State.cursor1.posB = nil
+      Text.populate_screen_line_starting_pos(State, State.cursor1.line)
+      local prev_line_cache = State.line_cache[State.cursor1.line]
+      local prev_screen_line_starting_pos = prev_line_cache.screen_line_starting_pos[#prev_line_cache.screen_line_starting_pos]
+      local prev_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, prev_screen_line_starting_pos)
+      local s = string.sub(State.lines[State.cursor1.line].data, prev_screen_line_starting_byte_offset)
+      State.cursor1.pos = prev_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
+    end
+  elseif screen_line_indexB == 2 then
+    -- all-B screen-line to potentially A+B screen-line
+    local xA = Margin_left + Text.screen_line_width(State, State.cursor1.line, #line_cache.screen_line_starting_pos) + AB_padding
+    if State.cursor_x < xA then
+      State.cursor1.posB = nil
+      Text.populate_screen_line_starting_pos(State, State.cursor1.line)
+      local new_screen_line_starting_pos = line_cache.screen_line_starting_pos[#line_cache.screen_line_starting_pos]
+      local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
+      local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
+      State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
+    else
+      Text.populate_screen_line_starting_posB(State, State.cursor1.line)
+      local new_screen_line_starting_posB = line_cache.screen_line_starting_posB[screen_line_indexB-1]
+      local new_screen_line_starting_byte_offsetB = Text.offset(State.lines[State.cursor1.line].dataB, new_screen_line_starting_posB)
+      local s = string.sub(State.lines[State.cursor1.line].dataB, new_screen_line_starting_byte_offsetB)
+      State.cursor1.posB = new_screen_line_starting_posB + Text.nearest_cursor_pos(s, State.cursor_x-xA, State.left) - 1
+    end
+  else
+    assert(screen_line_indexB > 2)
+    -- all-B screen-line to all-B screen-line
+    Text.populate_screen_line_starting_posB(State, State.cursor1.line)
+    local new_screen_line_starting_posB = line_cache.screen_line_starting_posB[screen_line_indexB-1]
+    local new_screen_line_starting_byte_offsetB = Text.offset(State.lines[State.cursor1.line].dataB, new_screen_line_starting_posB)
+    local s = string.sub(State.lines[State.cursor1.line].dataB, new_screen_line_starting_byte_offsetB)
+    State.cursor1.posB = new_screen_line_starting_posB + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
+  end
+  if Text.lt1(State.cursor1, State.screen_top1) then
+    local top2 = Text.to2(State, State.screen_top1)
+    top2 = Text.previous_screen_line(State, top2)
+    State.screen_top1 = Text.to1(State, top2)
+  end
+end
+
+-- cursor on final screen line (A or B side) => goes to next screen line on A side
+-- cursor on A side => move down one screen line (A side) in current line
+-- cursor on B side => move down one screen line (B side) in current line
+function Text.down(State)
+--?   print('down', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
+  if Text.cursor_at_final_screen_line(State) then
+    -- line is done, skip to next text line
+--?     print('cursor at final screen line of its line')
+    if State.cursor1.line < #State.lines then
+      State.cursor1 = {
+        line = State.cursor1.line+1,
+        pos = Text.nearest_cursor_pos(State.lines[State.cursor1.line+1].data, State.cursor_x, State.left)
+      }
+--?       print(State.cursor1.pos)
+    end
+    if State.cursor1.line > State.screen_bottom1.line then
+--?       print('screen top before:', State.screen_top1.line, State.screen_top1.pos)
+--?       print('scroll up preserving cursor')
+      Text.snap_cursor_to_bottom_of_screen(State)
+--?       print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
+    end
+  elseif State.cursor1.pos then
+    -- move down one screen line (A side) in current line
+    local scroll_down = Text.le1(State.screen_bottom1, State.cursor1)
+--?     print('cursor is NOT at final screen line of its line')
+    local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1)
+    Text.populate_screen_line_starting_pos(State, State.cursor1.line)
+    local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index+1]
+--?     print('switching pos of screen line at cursor from '..tostring(screen_line_starting_pos)..' to '..tostring(new_screen_line_starting_pos))
+    local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
+    local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
+    State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
+--?     print('cursor pos is now', State.cursor1.line, State.cursor1.pos)
+    if scroll_down then
+--?       print('scroll up preserving cursor')
+      Text.snap_cursor_to_bottom_of_screen(State)
+--?       print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
+    end
+  else
+    -- move down one screen line (B side) in current line
+    local scroll_down = false
+    if Text.le1(State.screen_bottom1, State.cursor1) then
+      scroll_down = true
+    end
+    local cursor_line = State.lines[State.cursor1.line]
+    local cursor_line_cache = State.line_cache[State.cursor1.line]
+    local cursor2 = Text.to2(State, State.cursor1)
+    assert(cursor2.screen_lineB < #cursor_line_cache.screen_line_starting_posB)
+    local screen_line_starting_posB, screen_line_indexB = Text.pos_at_start_of_screen_lineB(State, State.cursor1)
+    Text.populate_screen_line_starting_posB(State, State.cursor1.line)
+    local new_screen_line_starting_posB = cursor_line_cache.screen_line_starting_posB[screen_line_indexB+1]
+    local new_screen_line_starting_byte_offsetB = Text.offset(cursor_line.dataB, new_screen_line_starting_posB)
+    local s = string.sub(cursor_line.dataB, new_screen_line_starting_byte_offsetB)
+    State.cursor1.posB = new_screen_line_starting_posB + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
+    if scroll_down then
+      Text.snap_cursor_to_bottom_of_screen(State)
+    end
+  end
+--?   print('=>', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
+end
+
+function Text.start_of_line(State)
+  if State.cursor1.pos then
+    State.cursor1.pos = 1
+  else
+    State.cursor1.posB = 1
+  end
+  if Text.lt1(State.cursor1, State.screen_top1) then
+    State.screen_top1 = {line=State.cursor1.line, pos=1}  -- copy
+  end
+end
+
+function Text.end_of_line(State)
+  if State.cursor1.pos then
+    State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + 1
+  else
+    State.cursor1.posB = utf8.len(State.lines[State.cursor1.line].dataB) + 1
+  end
+  if Text.cursor_out_of_screen(State) then
+    Text.snap_cursor_to_bottom_of_screen(State)
+  end
+end
+
+function Text.word_left(State)
+  -- we can cross the fold, so check side A/B one level down
+  Text.skip_whitespace_left(State)
+  Text.left(State)
+  Text.skip_non_whitespace_left(State)
+end
+
+function Text.word_right(State)
+  -- we can cross the fold, so check side A/B one level down
+  Text.skip_whitespace_right(State)
+  Text.right(State)
+  Text.skip_non_whitespace_right(State)
+  if Text.cursor_out_of_screen(State) then
+    Text.snap_cursor_to_bottom_of_screen(State)
+  end
+end
+
+function Text.skip_whitespace_left(State)
+  if State.cursor1.pos then
+    Text.skip_whitespace_leftA(State)
+  else
+    Text.skip_whitespace_leftB(State)
+  end
+end
+
+function Text.skip_non_whitespace_left(State)
+  if State.cursor1.pos then
+    Text.skip_non_whitespace_leftA(State)
+  else
+    Text.skip_non_whitespace_leftB(State)
+  end
+end
+
+function Text.skip_whitespace_leftA(State)
+  while true do
+    if State.cursor1.pos == 1 then
+      break
+    end
+    if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%S') then
+      break
+    end
+    Text.left(State)
+  end
+end
+
+function Text.skip_whitespace_leftB(State)
+  while true do
+    if State.cursor1.posB == 1 then
+      break
+    end
+    if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB-1, '%S') then
+      break
+    end
+    Text.left(State)
+  end
+end
+
+function Text.skip_non_whitespace_leftA(State)
+  while true do
+    if State.cursor1.pos == 1 then
+      break
+    end
+    assert(State.cursor1.pos > 1)
+    if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%s') then
+      break
+    end
+    Text.left(State)
+  end
+end
+
+function Text.skip_non_whitespace_leftB(State)
+  while true do
+    if State.cursor1.posB == 1 then
+      break
+    end
+    assert(State.cursor1.posB > 1)
+    if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB-1, '%s') then
+      break
+    end
+    Text.left(State)
+  end
+end
+
+function Text.skip_whitespace_right(State)
+  if State.cursor1.pos then
+    Text.skip_whitespace_rightA(State)
+  else
+    Text.skip_whitespace_rightB(State)
+  end
+end
+
+function Text.skip_non_whitespace_right(State)
+  if State.cursor1.pos then
+    Text.skip_non_whitespace_rightA(State)
+  else
+    Text.skip_non_whitespace_rightB(State)
+  end
+end
+
+function Text.skip_whitespace_rightA(State)
+  while true do
+    if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then
+      break
+    end
+    if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%S') then
+      break
+    end
+    Text.right_without_scroll(State)
+  end
+end
+
+function Text.skip_whitespace_rightB(State)
+  while true do
+    if State.cursor1.posB > utf8.len(State.lines[State.cursor1.line].dataB) then
+      break
+    end
+    if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB, '%S') then
+      break
+    end
+    Text.right_without_scroll(State)
+  end
+end
+
+function Text.skip_non_whitespace_rightA(State)
+  while true do
+    if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then
+      break
+    end
+    if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%s') then
+      break
+    end
+    Text.right_without_scroll(State)
+  end
+end
+
+function Text.skip_non_whitespace_rightB(State)
+  while true do
+    if State.cursor1.posB > utf8.len(State.lines[State.cursor1.line].dataB) then
+      break
+    end
+    if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB, '%s') then
+      break
+    end
+    Text.right_without_scroll(State)
+  end
+end
+
+function Text.match(s, pos, pat)
+  local start_offset = Text.offset(s, pos)
+  assert(start_offset)
+  local end_offset = Text.offset(s, pos+1)
+  assert(end_offset > start_offset)
+  local curr = s:sub(start_offset, end_offset-1)
+  return curr:match(pat)
+end
+
+function Text.left(State)
+  if State.cursor1.pos then
+    Text.leftA(State)
+  else
+    Text.leftB(State)
+  end
+end
+
+function Text.leftA(State)
+  if State.cursor1.pos > 1 then
+    State.cursor1.pos = State.cursor1.pos-1
+  elseif State.cursor1.line > 1 then
+    State.cursor1 = {
+      line = State.cursor1.line-1,
+      pos = utf8.len(State.lines[State.cursor1.line-1].data) + 1,
+    }
+  end
+  if Text.lt1(State.cursor1, State.screen_top1) then
+    local top2 = Text.to2(State, State.screen_top1)
+    top2 = Text.previous_screen_line(State, top2)
+    State.screen_top1 = Text.to1(State, top2)
+  end
+end
+
+function Text.leftB(State)
+  if State.cursor1.posB > 1 then
+    State.cursor1.posB = State.cursor1.posB-1
+  else
+    -- overflow back into A side
+    State.cursor1.posB = nil
+    State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + 1
+  end
+  if Text.lt1(State.cursor1, State.screen_top1) then
+    local top2 = Text.to2(State, State.screen_top1)
+    top2 = Text.previous_screen_line(State, top2)
+    State.screen_top1 = Text.to1(State, top2)
+  end
+end
+
+function Text.right(State)
+  Text.right_without_scroll(State)
+  if Text.cursor_out_of_screen(State) then
+    Text.snap_cursor_to_bottom_of_screen(State)
+  end
+end
+
+function Text.right_without_scroll(State)
+  if State.cursor1.pos then
+    Text.right_without_scrollA(State)
+  else
+    Text.right_without_scrollB(State)
+  end
+end
+
+function Text.right_without_scrollA(State)
+  if State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
+    State.cursor1.pos = State.cursor1.pos+1
+  elseif State.cursor1.line <= #State.lines-1 then
+    State.cursor1 = {line=State.cursor1.line+1, pos=1}
+  end
+end
+
+function Text.right_without_scrollB(State)
+  if State.cursor1.posB <= utf8.len(State.lines[State.cursor1.line].dataB) then
+    State.cursor1.posB = State.cursor1.posB+1
+  elseif State.cursor1.line <= #State.lines-1 then
+    -- overflow back into A side
+    State.cursor1 = {line=State.cursor1.line+1, pos=1}
+  end
+end
+
+function Text.pos_at_start_of_screen_line(State, loc1)
+  Text.populate_screen_line_starting_pos(State, loc1.line)
+  local line_cache = State.line_cache[loc1.line]
+  for i=#line_cache.screen_line_starting_pos,1,-1 do
+    local spos = line_cache.screen_line_starting_pos[i]
+    if spos <= loc1.pos then
+      return spos,i
+    end
+  end
+  assert(false)
+end
+
+function Text.pos_at_start_of_screen_lineB(State, loc1)
+  Text.populate_screen_line_starting_pos(State, loc1.line)
+  local line_cache = State.line_cache[loc1.line]
+  local x = Margin_left + Text.screen_line_width(State, loc1.line, #line_cache.screen_line_starting_pos) + AB_padding
+  Text.populate_screen_line_starting_posB(State, loc1.line, x)
+  for i=#line_cache.screen_line_starting_posB,1,-1 do
+    local sposB = line_cache.screen_line_starting_posB[i]
+    if sposB <= loc1.posB then
+      return sposB,i
+    end
+  end
+  assert(false)
+end
+
+function Text.cursor_at_final_screen_line(State)
+  Text.populate_screen_line_starting_pos(State, State.cursor1.line)
+  local line = State.lines[State.cursor1.line]
+  local screen_lines = State.line_cache[State.cursor1.line].screen_line_starting_pos
+--?   print(screen_lines[#screen_lines], State.cursor1.pos)
+  if (not State.expanded and not line.expanded) or
+      line.dataB == nil then
+    return screen_lines[#screen_lines] <= State.cursor1.pos
+  end
+  if State.cursor1.pos then
+    -- ignore B side
+    return screen_lines[#screen_lines] <= State.cursor1.pos
+  end
+  assert(State.cursor1.posB)
+  local line_cache = State.line_cache[State.cursor1.line]
+  local x = Margin_left + Text.screen_line_width(State, State.cursor1.line, #line_cache.screen_line_starting_pos) + AB_padding
+  Text.populate_screen_line_starting_posB(State, State.cursor1.line, x)
+  local screen_lines = State.line_cache[State.cursor1.line].screen_line_starting_posB
+  return screen_lines[#screen_lines] <= State.cursor1.posB
+end
+
+function Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
+  if State.top > App.screen.height - State.line_height then
+--?     print('scroll up')
+    Text.snap_cursor_to_bottom_of_screen(State)
+  end
+end
+
+-- should never modify State.cursor1
+function Text.snap_cursor_to_bottom_of_screen(State)
+--?   print('to2:', State.cursor1.line, State.cursor1.pos, State.cursor1.posB)
+  local top2 = Text.to2(State, State.cursor1)
+--?   print('to2: =>', top2.line, top2.screen_line, top2.screen_pos, top2.screen_lineB, top2.screen_posB)
+  -- slide to start of screen line
+  if top2.screen_pos then
+    top2.screen_pos = 1
+  else
+    assert(top2.screen_posB)
+    top2.screen_posB = 1
+  end
+--?   print('snap', State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.cursor1.posB, State.screen_bottom1.line, State.screen_bottom1.pos, State.screen_bottom1.posB)
+--?   print('cursor pos '..tostring(State.cursor1.pos)..' is on the #'..tostring(top2.screen_line)..' screen line down')
+  local y = App.screen.height - State.line_height
+  -- duplicate some logic from love.draw
+  while true do
+--?     print(y, 'top2:', State.lines[top2.line].data, top2.line, top2.screen_line, top2.screen_pos, top2.screen_lineB, top2.screen_posB)
+    if top2.line == 1 and top2.screen_line == 1 then break end
+    local h = State.line_height
+    if y - h < State.top then
+      break
+    end
+    y = y - h
+    top2 = Text.previous_screen_line(State, top2)
+  end
+--?   print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
+  State.screen_top1 = Text.to1(State, top2)
+--?   print('top1 finally:', State.screen_top1.line, State.screen_top1.pos)
+--?   print('snap =>', State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.cursor1.posB, State.screen_bottom1.line, State.screen_bottom1.pos, State.screen_bottom1.posB)
+  Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+  Foo = true
+end
+
+function Text.in_line(State, line_index, x,y)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  if line_cache.starty == nil then return false end  -- outside current page
+  if y < line_cache.starty then return false end
+  local num_screen_lines = 0
+  if line_cache.startpos then
+    Text.populate_screen_line_starting_pos(State, line_index)
+    num_screen_lines = num_screen_lines + #line_cache.screen_line_starting_pos - Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos) + 1
+  end
+--?   print('#screenlines after A', num_screen_lines)
+  if line.dataB and (State.expanded or line.expanded) then
+    local x = Margin_left + Text.screen_line_width(State, line_index, #line_cache.screen_line_starting_pos) + AB_padding
+    Text.populate_screen_line_starting_posB(State, line_index, x)
+--?     print('B:', x, #line_cache.screen_line_starting_posB)
+    if line_cache.startposB then
+      num_screen_lines = num_screen_lines + #line_cache.screen_line_starting_posB - Text.screen_line_indexB(line_cache.screen_line_starting_posB, line_cache.startposB)  -- no +1; first screen line of B side overlaps with A side
+    else
+      num_screen_lines = num_screen_lines + #line_cache.screen_line_starting_posB - Text.screen_line_indexB(line_cache.screen_line_starting_posB, 1)  -- no +1; first screen line of B side overlaps with A side
+    end
+  end
+--?   print('#screenlines after B', num_screen_lines)
+  return y < line_cache.starty + State.line_height*num_screen_lines
+end
+
+-- convert mx,my in pixels to schema-1 coordinates
+-- returns: pos, posB
+-- scenarios:
+--   line without B side
+--   line with B side collapsed
+--   line with B side expanded
+--   line starting rendering in A side (startpos ~= nil)
+--   line starting rendering in B side (startposB ~= nil)
+--   my on final screen line of A side
+--     mx to right of A side with no B side
+--     mx to right of A side but left of B side
+--     mx to right of B side
+-- preconditions:
+--  startpos xor startposB
+--  expanded -> dataB
+function Text.to_pos_on_line(State, line_index, mx, my)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  assert(my >= line_cache.starty)
+  -- duplicate some logic from Text.draw
+  local y = line_cache.starty
+--?   print('click', line_index, my, 'with line starting at', y, #line_cache.screen_line_starting_pos)  -- , #line_cache.screen_line_starting_posB)
+  if line_cache.startpos then
+    local start_screen_line_index = Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos)
+    for screen_line_index = start_screen_line_index,#line_cache.screen_line_starting_pos do
+      local screen_line_starting_pos = line_cache.screen_line_starting_pos[screen_line_index]
+      local screen_line_starting_byte_offset = Text.offset(line.data, screen_line_starting_pos)
+--?       print('iter', y, screen_line_index, screen_line_starting_pos, string.sub(line.data, screen_line_starting_byte_offset))
+      local nexty = y + State.line_height
+      if my < nexty then
+        -- On all wrapped screen lines but the final one, clicks past end of
+        -- line position cursor on final character of screen line.
+        -- (The final screen line positions past end of screen line as always.)
+        if screen_line_index < #line_cache.screen_line_starting_pos and mx > State.left + Text.screen_line_width(State, line_index, screen_line_index) then
+--?           print('past end of non-final line; return')
+          return line_cache.screen_line_starting_pos[screen_line_index+1]-1
+        end
+        local s = string.sub(line.data, screen_line_starting_byte_offset)
+--?         print('return', mx, Text.nearest_cursor_pos(s, mx, State.left), '=>', screen_line_starting_pos + Text.nearest_cursor_pos(s, mx, State.left) - 1)
+        local screen_line_posA = Text.nearest_cursor_pos(s, mx, State.left)
+        if line.dataB == nil then
+          -- no B side
+          return screen_line_starting_pos + screen_line_posA - 1
+        end
+        if not State.expanded and not line.expanded then
+          -- B side is not expanded
+          return screen_line_starting_pos + screen_line_posA - 1
+        end
+        local lenA = utf8.len(s)
+        if screen_line_posA < lenA then
+          -- mx is within A side
+          return screen_line_starting_pos + screen_line_posA - 1
+        end
+        local max_xA = State.left+Text.x(s, lenA+1)
+        if mx < max_xA + AB_padding then
+          -- mx is in the space between A and B side
+          return screen_line_starting_pos + screen_line_posA - 1
+        end
+        mx = mx - max_xA - AB_padding
+        local screen_line_posB = Text.nearest_cursor_pos(line.dataB, mx, --[[no left margin]] 0)
+        return nil, screen_line_posB
+      end
+      y = nexty
+    end
+  end
+  -- look in screen lines composed entirely of the B side
+  assert(State.expanded or line.expanded)
+  local start_screen_line_indexB
+  if line_cache.startposB then
+    start_screen_line_indexB = Text.screen_line_indexB(line_cache.screen_line_starting_posB, line_cache.startposB)
+  else
+    start_screen_line_indexB = 2  -- skip the first line of side B, which we checked above
+  end
+  for screen_line_indexB = start_screen_line_indexB,#line_cache.screen_line_starting_posB do
+    local screen_line_starting_posB = line_cache.screen_line_starting_posB[screen_line_indexB]
+    local screen_line_starting_byte_offsetB = Text.offset(line.dataB, screen_line_starting_posB)
+--?     print('iter2', y, screen_line_indexB, screen_line_starting_posB, string.sub(line.dataB, screen_line_starting_byte_offsetB))
+    local nexty = y + State.line_height
+    if my < nexty then
+      -- On all wrapped screen lines but the final one, clicks past end of
+      -- line position cursor on final character of screen line.
+      -- (The final screen line positions past end of screen line as always.)
+--?       print('aa', mx, State.left, Text.screen_line_widthB(State, line_index, screen_line_indexB))
+      if screen_line_indexB < #line_cache.screen_line_starting_posB and mx > State.left + Text.screen_line_widthB(State, line_index, screen_line_indexB) then
+--?         print('past end of non-final line; return')
+        return nil, line_cache.screen_line_starting_posB[screen_line_indexB+1]-1
+      end
+      local s = string.sub(line.dataB, screen_line_starting_byte_offsetB)
+--?       print('return', mx, Text.nearest_cursor_pos(s, mx, State.left), '=>', screen_line_starting_posB + Text.nearest_cursor_pos(s, mx, State.left) - 1)
+      return nil, screen_line_starting_posB + Text.nearest_cursor_pos(s, mx, State.left) - 1
+    end
+    y = nexty
+  end
+  assert(false)
+end
+
+function Text.screen_line_width(State, line_index, i)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  local start_pos = line_cache.screen_line_starting_pos[i]
+  local start_offset = Text.offset(line.data, start_pos)
+  local screen_line
+  if i < #line_cache.screen_line_starting_pos then
+    local past_end_pos = line_cache.screen_line_starting_pos[i+1]
+    local past_end_offset = Text.offset(line.data, past_end_pos)
+    screen_line = string.sub(line.data, start_offset, past_end_offset-1)
+  else
+    screen_line = string.sub(line.data, start_pos)
+  end
+  local screen_line_text = App.newText(love.graphics.getFont(), screen_line)
+  return App.width(screen_line_text)
+end
+
+function Text.screen_line_widthB(State, line_index, i)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  local start_posB = line_cache.screen_line_starting_posB[i]
+  local start_offsetB = Text.offset(line.dataB, start_posB)
+  local screen_line
+  if i < #line_cache.screen_line_starting_posB then
+--?     print('non-final', i)
+    local past_end_posB = line_cache.screen_line_starting_posB[i+1]
+    local past_end_offsetB = Text.offset(line.dataB, past_end_posB)
+--?     print('between', start_offsetB, past_end_offsetB)
+    screen_line = string.sub(line.dataB, start_offsetB, past_end_offsetB-1)
+  else
+--?     print('final', i)
+--?     print('after', start_offsetB)
+    screen_line = string.sub(line.dataB, start_offsetB)
+  end
+  local screen_line_text = App.newText(love.graphics.getFont(), screen_line)
+--?   local result = App.width(screen_line_text)
+--?   print('=>', result)
+--?   return result
+  return App.width(screen_line_text)
+end
+
+function Text.screen_line_index(screen_line_starting_pos, pos)
+  for i = #screen_line_starting_pos,1,-1 do
+    if screen_line_starting_pos[i] <= pos then
+      return i
+    end
+  end
+end
+
+function Text.screen_line_indexB(screen_line_starting_posB, posB)
+  if posB == nil then
+    return 0
+  end
+  assert(screen_line_starting_posB)
+  for i = #screen_line_starting_posB,1,-1 do
+    if screen_line_starting_posB[i] <= posB then
+      return i
+    end
+  end
+end
+
+-- convert x pixel coordinate to pos
+-- oblivious to wrapping
+-- result: 1 to len+1
+function Text.nearest_cursor_pos(line, x, left)
+  if x < left then
+    return 1
+  end
+  local len = utf8.len(line)
+  local max_x = left+Text.x(line, len+1)
+  if x > max_x then
+    return len+1
+  end
+  local leftpos, rightpos = 1, len+1
+--?   print('-- nearest', x)
+  while true do
+--?     print('nearest', x, '^'..line..'$', leftpos, rightpos)
+    if leftpos == rightpos then
+      return leftpos
+    end
+    local curr = math.floor((leftpos+rightpos)/2)
+    local currxmin = left+Text.x(line, curr)
+    local currxmax = left+Text.x(line, curr+1)
+--?     print('nearest', x, leftpos, rightpos, curr, currxmin, currxmax)
+    if currxmin <= x and x < currxmax then
+      if x-currxmin < currxmax-x then
+        return curr
+      else
+        return curr+1
+      end
+    end
+    if leftpos >= rightpos-1 then
+      return rightpos
+    end
+    if currxmin > x then
+      rightpos = curr
+    else
+      leftpos = curr
+    end
+  end
+  assert(false)
+end
+
+-- return the nearest index of line (in utf8 code points) which lies entirely
+-- within x pixels of the left margin
+-- result: 0 to len+1
+function Text.nearest_pos_less_than(line, x)
+--?   print('', '-- nearest_pos_less_than', line, x)
+  local len = utf8.len(line)
+  local max_x = Text.x_after(line, len)
+  if x > max_x then
+    return len+1
+  end
+  local left, right = 0, len+1
+  while true do
+    local curr = math.floor((left+right)/2)
+    local currxmin = Text.x_after(line, curr+1)
+    local currxmax = Text.x_after(line, curr+2)
+--?     print('', x, left, right, curr, currxmin, currxmax)
+    if currxmin <= x and x < currxmax then
+      return curr
+    end
+    if left >= right-1 then
+      return left
+    end
+    if currxmin > x then
+      right = curr
+    else
+      left = curr
+    end
+  end
+  assert(false)
+end
+
+function Text.x_after(s, pos)
+  local offset = Text.offset(s, math.min(pos+1, #s+1))
+  local s_before = s:sub(1, offset-1)
+--?   print('^'..s_before..'$')
+  local text_before = App.newText(love.graphics.getFont(), s_before)
+  return App.width(text_before)
+end
+
+function Text.x(s, pos)
+  local offset = Text.offset(s, pos)
+  local s_before = s:sub(1, offset-1)
+  local text_before = App.newText(love.graphics.getFont(), s_before)
+  return App.width(text_before)
+end
+
+function Text.to2(State, loc1)
+  if loc1.pos then
+    return Text.to2A(State, loc1)
+  else
+    return Text.to2B(State, loc1)
+  end
+end
+
+function Text.to2A(State, loc1)
+  local result = {line=loc1.line}
+  local line_cache = State.line_cache[loc1.line]
+  Text.populate_screen_line_starting_pos(State, loc1.line)
+  for i=#line_cache.screen_line_starting_pos,1,-1 do
+    local spos = line_cache.screen_line_starting_pos[i]
+    if spos <= loc1.pos then
+      result.screen_line = i
+      result.screen_pos = loc1.pos - spos + 1
+      break
+    end
+  end
+  assert(result.screen_pos)
+  return result
+end
+
+function Text.to2B(State, loc1)
+  local result = {line=loc1.line}
+  local line_cache = State.line_cache[loc1.line]
+  Text.populate_screen_line_starting_pos(State, loc1.line)
+  local x = Margin_left + Text.screen_line_width(State, loc1.line, #line_cache.screen_line_starting_pos) + AB_padding
+  Text.populate_screen_line_starting_posB(State, loc1.line, x)
+  for i=#line_cache.screen_line_starting_posB,1,-1 do
+    local sposB = line_cache.screen_line_starting_posB[i]
+    if sposB <= loc1.posB then
+      result.screen_lineB = i
+      result.screen_posB = loc1.posB - sposB + 1
+      break
+    end
+  end
+  assert(result.screen_posB)
+  return result
+end
+
+function Text.to1(State, loc2)
+  if loc2.screen_pos then
+    return Text.to1A(State, loc2)
+  else
+    return Text.to1B(State, loc2)
+  end
+end
+
+function Text.to1A(State, loc2)
+  local result = {line=loc2.line, pos=loc2.screen_pos}
+  if loc2.screen_line > 1 then
+    result.pos = State.line_cache[loc2.line].screen_line_starting_pos[loc2.screen_line] + loc2.screen_pos - 1
+  end
+  return result
+end
+
+function Text.to1B(State, loc2)
+  local result = {line=loc2.line, posB=loc2.screen_posB}
+  if loc2.screen_lineB > 1 then
+    result.posB = State.line_cache[loc2.line].screen_line_starting_posB[loc2.screen_lineB] + loc2.screen_posB - 1
+  end
+  return result
+end
+
+function Text.lt1(a, b)
+  if a.line < b.line then
+    return true
+  end
+  if a.line > b.line then
+    return false
+  end
+  -- A side < B side
+  if a.pos and not b.pos then
+    return true
+  end
+  if not a.pos and b.pos then
+    return false
+  end
+  if a.pos then
+    return a.pos < b.pos
+  else
+    return a.posB < b.posB
+  end
+end
+
+function Text.le1(a, b)
+  return eq(a, b) or Text.lt1(a, b)
+end
+
+function Text.offset(s, pos1)
+  if pos1 == 1 then return 1 end
+  local result = utf8.offset(s, pos1)
+  if result == nil then
+    print(pos1, #s, s)
+  end
+  assert(result)
+  return result
+end
+
+function Text.previous_screen_line(State, loc2)
+  if loc2.screen_pos then
+    return Text.previous_screen_lineA(State, loc2)
+  else
+    return Text.previous_screen_lineB(State, loc2)
+  end
+end
+
+function Text.previous_screen_lineA(State, loc2)
+  if loc2.screen_line > 1 then
+--?     print('a')
+    return {line=loc2.line, screen_line=loc2.screen_line-1, screen_pos=1}
+  elseif loc2.line == 1 then
+--?     print('b')
+    return loc2
+  else
+    Text.populate_screen_line_starting_pos(State, loc2.line-1)
+    if State.lines[loc2.line-1].dataB == nil or
+        (not State.expanded and not State.lines[loc2.line-1].expanded) then
+--?       print('c1', loc2.line-1, State.lines[loc2.line-1].data, '==', State.lines[loc2.line-1].dataB, State.line_cache[loc2.line-1].fragmentsB)
+      return {line=loc2.line-1, screen_line=#State.line_cache[loc2.line-1].screen_line_starting_pos, screen_pos=1}
+    end
+    -- try to switch to B
+    local prev_line_cache = State.line_cache[loc2.line-1]
+    local x = Margin_left + Text.screen_line_width(State, loc2.line-1, #prev_line_cache.screen_line_starting_pos) + AB_padding
+    Text.populate_screen_line_starting_posB(State, loc2.line-1, x)
+    local screen_line_starting_posB = State.line_cache[loc2.line-1].screen_line_starting_posB
+--?     print('c', loc2.line-1, State.lines[loc2.line-1].data, '==', State.lines[loc2.line-1].dataB, '==', #screen_line_starting_posB, 'starting from x', x)
+    if #screen_line_starting_posB > 1 then
+--?       print('c2')
+      return {line=loc2.line-1, screen_lineB=#State.line_cache[loc2.line-1].screen_line_starting_posB, screen_posB=1}
+    else
+--?       print('c3')
+      -- if there's only one screen line, assume it overlaps with A, so remain in A
+      return {line=loc2.line-1, screen_line=#State.line_cache[loc2.line-1].screen_line_starting_pos, screen_pos=1}
+    end
+  end
+end
+
+function Text.previous_screen_lineB(State, loc2)
+  if loc2.screen_lineB > 2 then  -- first screen line of B side overlaps with A side
+    return {line=loc2.line, screen_lineB=loc2.screen_lineB-1, screen_posB=1}
+  else
+    -- switch to A side
+    -- TODO: handle case where fold lands precisely at end of a new screen-line
+    return {line=loc2.line, screen_line=#State.line_cache[loc2.line].screen_line_starting_pos, screen_pos=1}
+  end
+end
+
+-- resize helper
+function Text.tweak_screen_top_and_cursor(State)
+  if State.screen_top1.pos == 1 then return end
+  Text.populate_screen_line_starting_pos(State, State.screen_top1.line)
+  local line = State.lines[State.screen_top1.line]
+  local line_cache = State.line_cache[State.screen_top1.line]
+  for i=2,#line_cache.screen_line_starting_pos do
+    local pos = line_cache.screen_line_starting_pos[i]
+    if pos == State.screen_top1.pos then
+      break
+    end
+    if pos > State.screen_top1.pos then
+      -- make sure screen top is at start of a screen line
+      local prev = line_cache.screen_line_starting_pos[i-1]
+      if State.screen_top1.pos - prev < pos - State.screen_top1.pos then
+        State.screen_top1.pos = prev
+      else
+        State.screen_top1.pos = pos
+      end
+      break
+    end
+  end
+  -- make sure cursor is on screen
+  if Text.lt1(State.cursor1, State.screen_top1) then
+    State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+  elseif State.cursor1.line >= State.screen_bottom1.line then
+--?     print('too low')
+    if Text.cursor_out_of_screen(State) then
+--?       print('tweak')
+      local pos,posB = Text.to_pos_on_line(State, State.screen_bottom1.line, State.right-5, App.screen.height-5)
+      State.cursor1 = {line=State.screen_bottom1.line, pos=pos, posB=posB}
+    end
+  end
+end
+
+-- slightly expensive since it redraws the screen
+function Text.cursor_out_of_screen(State)
+  App.draw()
+  return State.cursor_y == nil
+  -- this approach is cheaper and almost works, except on the final screen
+  -- where file ends above bottom of screen
+--?   local botpos = Text.pos_at_start_of_screen_line(State, State.cursor1)
+--?   local botline1 = {line=State.cursor1.line, pos=botpos}
+--?   return Text.lt1(State.screen_bottom1, botline1)
+end
+
+function Text.redraw_all(State)
+--?   print('clearing fragments')
+  State.line_cache = {}
+  for i=1,#State.lines do
+    State.line_cache[i] = {}
+  end
+end
+
+function Text.clear_screen_line_cache(State, line_index)
+  State.line_cache[line_index].fragments = nil
+  State.line_cache[line_index].fragmentsB = nil
+  State.line_cache[line_index].screen_line_starting_pos = nil
+  State.line_cache[line_index].screen_line_starting_posB = nil
+end
+
+function trim(s)
+  return s:gsub('^%s+', ''):gsub('%s+$', '')
+end
+
+function ltrim(s)
+  return s:gsub('^%s+', '')
+end
+
+function rtrim(s)
+  return s:gsub('%s+$', '')
+end
diff --git a/source_text_tests.lua b/source_text_tests.lua
new file mode 100644
index 0000000..ecffb13
--- /dev/null
+++ b/source_text_tests.lua
@@ -0,0 +1,1609 @@
+-- major tests for text editing flows
+
+function test_initial_state()
+  io.write('\ntest_initial_state')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{}
+  Text.redraw_all(Editor_state)
+  edit.draw(Editor_state)
+  check_eq(#Editor_state.lines, 1, 'F - test_initial_state/#lines')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_initial_state/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_initial_state/cursor:pos')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_initial_state/screen_top:line')
+  check_eq(Editor_state.screen_top1.pos, 1, 'F - test_initial_state/screen_top:pos')
+end
+
+function test_backspace_from_start_of_final_line()
+  io.write('\ntest_backspace_from_start_of_final_line')
+  -- display final line of text with cursor at start of it
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def'}
+  Editor_state.screen_top1 = {line=2, pos=1}
+  Editor_state.cursor1 = {line=2, pos=1}
+  Text.redraw_all(Editor_state)
+  -- backspace scrolls up
+  edit.run_after_keychord(Editor_state, 'backspace')
+  check_eq(#Editor_state.lines, 1, 'F - test_backspace_from_start_of_final_line/#lines')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_backspace_from_start_of_final_line/cursor')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_backspace_from_start_of_final_line/screen_top')
+end
+
+function test_insert_first_character()
+  io.write('\ntest_insert_first_character')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{}
+  Text.redraw_all(Editor_state)
+  edit.draw(Editor_state)
+  edit.run_after_textinput(Editor_state, 'a')
+  local y = Editor_state.top
+  App.screen.check(y, 'a', 'F - test_insert_first_character/screen:1')
+end
+
+function test_press_ctrl()
+  io.write('\ntest_press_ctrl')
+  -- press ctrl while the cursor is on text
+  App.screen.init{width=50, height=80}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{''}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.run_after_keychord(Editor_state, 'C-m')
+end
+
+function test_move_left()
+  io.write('\ntest_move_left')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'a'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=2}
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'left')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_move_left')
+end
+
+function test_move_right()
+  io.write('\ntest_move_right')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'a'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'right')
+  check_eq(Editor_state.cursor1.pos, 2, 'F - test_move_right')
+end
+
+function test_move_left_to_previous_line()
+  io.write('\ntest_move_left_to_previous_line')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'left')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_move_left_to_previous_line/line')
+  check_eq(Editor_state.cursor1.pos, 4, 'F - test_move_left_to_previous_line/pos')  -- past end of line
+end
+
+function test_move_right_to_next_line()
+  io.write('\ntest_move_right_to_next_line')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=4}  -- past end of line
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'right')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_move_right_to_next_line/line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_move_right_to_next_line/pos')
+end
+
+function test_move_to_start_of_word()
+  io.write('\ntest_move_to_start_of_word')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=3}
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-left')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_move_to_start_of_word')
+end
+
+function test_move_to_start_of_previous_word()
+  io.write('\ntest_move_to_start_of_previous_word')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=4}  -- at the space between words
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-left')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_move_to_start_of_previous_word')
+end
+
+function test_skip_to_previous_word()
+  io.write('\ntest_skip_to_previous_word')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=5}  -- at the start of second word
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-left')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_skip_to_previous_word')
+end
+
+function test_skip_past_tab_to_previous_word()
+  io.write('\ntest_skip_past_tab_to_previous_word')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def\tghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=10}  -- within third word
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-left')
+  check_eq(Editor_state.cursor1.pos, 9, 'F - test_skip_past_tab_to_previous_word')
+end
+
+function test_skip_multiple_spaces_to_previous_word()
+  io.write('\ntest_skip_multiple_spaces_to_previous_word')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc  def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=6}  -- at the start of second word
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-left')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_skip_multiple_spaces_to_previous_word')
+end
+
+function test_move_to_start_of_word_on_previous_line()
+  io.write('\ntest_move_to_start_of_word_on_previous_line')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def', 'ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-left')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_move_to_start_of_word_on_previous_line/line')
+  check_eq(Editor_state.cursor1.pos, 5, 'F - test_move_to_start_of_word_on_previous_line/pos')
+end
+
+function test_move_past_end_of_word()
+  io.write('\ntest_move_past_end_of_word')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-right')
+  check_eq(Editor_state.cursor1.pos, 4, 'F - test_move_past_end_of_word')
+end
+
+function test_skip_to_next_word()
+  io.write('\ntest_skip_to_next_word')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=4}  -- at the space between words
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-right')
+  check_eq(Editor_state.cursor1.pos, 8, 'F - test_skip_to_next_word')
+end
+
+function test_skip_past_tab_to_next_word()
+  io.write('\ntest_skip_past_tab_to_next_word')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc\tdef'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}  -- at the space between words
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-right')
+  check_eq(Editor_state.cursor1.pos, 4, 'F - test_skip_past_tab_to_next_word')
+end
+
+function test_skip_multiple_spaces_to_next_word()
+  io.write('\ntest_skip_multiple_spaces_to_next_word')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc  def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=4}  -- at the start of second word
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-right')
+  check_eq(Editor_state.cursor1.pos, 9, 'F - test_skip_multiple_spaces_to_next_word')
+end
+
+function test_move_past_end_of_word_on_next_line()
+  io.write('\ntest_move_past_end_of_word_on_next_line')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def', 'ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=8}
+  edit.draw(Editor_state)
+  edit.run_after_keychord(Editor_state, 'M-right')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_move_past_end_of_word_on_next_line/line')
+  check_eq(Editor_state.cursor1.pos, 4, 'F - test_move_past_end_of_word_on_next_line/pos')
+end
+
+function test_click_with_mouse()
+  io.write('\ntest_click_with_mouse')
+  -- display two lines with cursor on one of them
+  App.screen.init{width=50, height=80}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- click on the other line
+  edit.draw(Editor_state)
+  edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1)
+  -- cursor moves
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse/cursor:line')
+end
+
+function test_click_with_mouse_to_left_of_line()
+  io.write('\ntest_click_with_mouse_to_left_of_line')
+  -- display a line with the cursor in the middle
+  App.screen.init{width=50, height=80}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=3}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- click to the left of the line
+  edit.draw(Editor_state)
+  edit.run_after_mouse_click(Editor_state, Editor_state.left-4,Editor_state.top+5, 1)
+  -- cursor moves to start of line
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_to_left_of_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_click_with_mouse_to_left_of_line/cursor:pos')
+end
+
+function test_click_with_mouse_takes_margins_into_account()
+  io.write('\ntest_click_with_mouse_takes_margins_into_account')
+  -- display two lines with cursor on one of them
+  App.screen.init{width=100, height=80}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.left = 50  -- occupy only right side of screen
+  Editor_state.lines = load_array{'abc', 'def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- click on the other line
+  edit.draw(Editor_state)
+  edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1)
+  -- cursor moves
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_takes_margins_into_account/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 2, 'F - test_click_with_mouse_takes_margins_into_account/cursor:pos')
+end
+
+function test_click_with_mouse_on_empty_line()
+  io.write('\ntest_click_with_mouse_on_empty_line')
+  -- display two lines with the first one empty
+  App.screen.init{width=50, height=80}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'', 'def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- click on the empty line
+  edit.draw(Editor_state)
+  edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1)
+  -- cursor moves
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_on_empty_line/cursor')
+end
+
+function test_draw_text()
+  io.write('\ntest_draw_text')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_draw_text/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_draw_text/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_draw_text/screen:3')
+end
+
+function test_draw_wrapping_text()
+  io.write('\ntest_draw_wrapping_text')
+  App.screen.init{width=50, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'defgh', 'xyz'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_draw_wrapping_text/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'de', 'F - test_draw_wrapping_text/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'fgh', 'F - test_draw_wrapping_text/screen:3')
+end
+
+function test_draw_word_wrapping_text()
+  io.write('\ntest_draw_word_wrapping_text')
+  App.screen.init{width=60, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc ', 'F - test_draw_word_wrapping_text/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def ', 'F - test_draw_word_wrapping_text/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_draw_word_wrapping_text/screen:3')
+end
+
+function test_click_with_mouse_on_wrapping_line()
+  io.write('\ntest_click_with_mouse_on_wrapping_line')
+  -- display two lines with cursor on one of them
+  App.screen.init{width=50, height=80}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def ghi jkl mno pqr stu'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=20}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- click on the other line
+  edit.draw(Editor_state)
+  edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1)
+  -- cursor moves
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_on_wrapping_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 2, 'F - test_click_with_mouse_on_wrapping_line/cursor:pos')
+end
+
+function test_click_with_mouse_on_wrapping_line_takes_margins_into_account()
+  io.write('\ntest_click_with_mouse_on_wrapping_line_takes_margins_into_account')
+  -- display two lines with cursor on one of them
+  App.screen.init{width=100, height=80}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.left = 50  -- occupy only right side of screen
+  Editor_state.lines = load_array{'abc def ghi jkl mno pqr stu'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=20}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- click on the other line
+  edit.draw(Editor_state)
+  edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1)
+  -- cursor moves
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_on_wrapping_line_takes_margins_into_account/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 2, 'F - test_click_with_mouse_on_wrapping_line_takes_margins_into_account/cursor:pos')
+end
+
+function test_draw_text_wrapping_within_word()
+  -- arrange a screen line that needs to be split within a word
+  io.write('\ntest_draw_text_wrapping_within_word')
+  App.screen.init{width=60, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abcd e fghijk', 'xyz'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abcd ', 'F - test_draw_text_wrapping_within_word/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'e fgh', 'F - test_draw_text_wrapping_within_word/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ijk', 'F - test_draw_text_wrapping_within_word/screen:3')
+end
+
+function test_draw_wrapping_text_containing_non_ascii()
+  -- draw a long line containing non-ASCII
+  io.write('\ntest_draw_wrapping_text_containing_non_ascii')
+  App.screen.init{width=60, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'madam I’m adam', 'xyz'}  -- notice the non-ASCII apostrophe
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'mad', 'F - test_draw_wrapping_text_containing_non_ascii/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'am I', 'F - test_draw_wrapping_text_containing_non_ascii/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, '’m a', 'F - test_draw_wrapping_text_containing_non_ascii/screen:3')
+end
+
+function test_click_on_wrapping_line()
+  io.write('\ntest_click_on_wrapping_line')
+  -- display a wrapping line
+  App.screen.init{width=75, height=80}
+  Editor_state = edit.initialize_test_state()
+                               --  12345678901234
+  Editor_state.lines = load_array{"madam I'm adam"}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'madam ', 'F - test_click_on_wrapping_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, "I'm ad", 'F - test_click_on_wrapping_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  -- click past end of second screen line
+  edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1)
+  -- cursor moves to end of screen line
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_click_on_wrapping_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 12, 'F - test_click_on_wrapping_line/cursor:pos')
+end
+
+function test_click_on_wrapping_line_rendered_from_partway_at_top_of_screen()
+  io.write('\ntest_click_on_wrapping_line_rendered_from_partway_at_top_of_screen')
+  -- display a wrapping line from its second screen line
+  App.screen.init{width=75, height=80}
+  Editor_state = edit.initialize_test_state()
+                               --  12345678901234
+  Editor_state.lines = load_array{"madam I'm adam"}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=8}
+  Editor_state.screen_top1 = {line=1, pos=7}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, "I'm ad", 'F - test_click_on_wrapping_line_rendered_from_partway_at_top_of_screen/baseline/screen:2')
+  y = y + Editor_state.line_height
+  -- click past end of second screen line
+  edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1)
+  -- cursor moves to end of screen line
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_click_on_wrapping_line_rendered_from_partway_at_top_of_screen/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 12, 'F - test_click_on_wrapping_line_rendered_from_partway_at_top_of_screen/cursor:pos')
+end
+
+function test_click_past_end_of_wrapping_line()
+  io.write('\ntest_click_past_end_of_wrapping_line')
+  -- display a wrapping line
+  App.screen.init{width=75, height=80}
+  Editor_state = edit.initialize_test_state()
+                               --  12345678901234
+  Editor_state.lines = load_array{"madam I'm adam"}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'madam ', 'F - test_click_past_end_of_wrapping_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, "I'm ad", 'F - test_click_past_end_of_wrapping_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'am', 'F - test_click_past_end_of_wrapping_line/baseline/screen:3')
+  y = y + Editor_state.line_height
+  -- click past the end of it
+  edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1)
+  -- cursor moves to end of line
+  check_eq(Editor_state.cursor1.pos, 15, 'F - test_click_past_end_of_wrapping_line/cursor')  -- one more than the number of UTF-8 code-points
+end
+
+function test_click_past_end_of_wrapping_line_containing_non_ascii()
+  io.write('\ntest_click_past_end_of_wrapping_line_containing_non_ascii')
+  -- display a wrapping line containing non-ASCII
+  App.screen.init{width=75, height=80}
+  Editor_state = edit.initialize_test_state()
+                               --  12345678901234
+  Editor_state.lines = load_array{'madam I’m adam'}  -- notice the non-ASCII apostrophe
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'madam ', 'F - test_click_past_end_of_wrapping_line_containing_non_ascii/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'I’m ad', 'F - test_click_past_end_of_wrapping_line_containing_non_ascii/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'am', 'F - test_click_past_end_of_wrapping_line_containing_non_ascii/baseline/screen:3')
+  y = y + Editor_state.line_height
+  -- click past the end of it
+  edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1)
+  -- cursor moves to end of line
+  check_eq(Editor_state.cursor1.pos, 15, 'F - test_click_past_end_of_wrapping_line_containing_non_ascii/cursor')  -- one more than the number of UTF-8 code-points
+end
+
+function test_click_past_end_of_word_wrapping_line()
+  io.write('\ntest_click_past_end_of_word_wrapping_line')
+  -- display a long line wrapping at a word boundary on a screen of more realistic length
+  App.screen.init{width=160, height=80}
+  Editor_state = edit.initialize_test_state()
+                                -- 0        1         2
+                                -- 123456789012345678901
+  Editor_state.lines = load_array{'the quick brown fox jumped over the lazy dog'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'the quick brown fox ', 'F - test_click_past_end_of_word_wrapping_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  -- click past the end of the screen line
+  edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1)
+  -- cursor moves to end of screen line
+  check_eq(Editor_state.cursor1.pos, 20, 'F - test_click_past_end_of_word_wrapping_line/cursor')
+end
+
+function test_edit_wrapping_text()
+  io.write('\ntest_edit_wrapping_text')
+  App.screen.init{width=50, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'xyz'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=4}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  edit.run_after_textinput(Editor_state, 'g')
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_edit_wrapping_text/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'de', 'F - test_edit_wrapping_text/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'fg', 'F - test_edit_wrapping_text/screen:3')
+end
+
+function test_insert_newline()
+  io.write('\ntest_insert_newline')
+  -- display a few lines
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=2}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_insert_newline/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_insert_newline/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_insert_newline/baseline/screen:3')
+  -- hitting the enter key splits the line
+  edit.run_after_keychord(Editor_state, 'return')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_insert_newline/screen_top')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_insert_newline/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_insert_newline/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'a', 'F - test_insert_newline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'bc', 'F - test_insert_newline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_insert_newline/screen:3')
+end
+
+function test_insert_newline_at_start_of_line()
+  io.write('\ntest_insert_newline_at_start_of_line')
+  -- display a line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- hitting the enter key splits the line
+  edit.run_after_keychord(Editor_state, 'return')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_insert_newline_at_start_of_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_insert_newline_at_start_of_line/cursor:pos')
+  check_eq(Editor_state.lines[1].data, '', 'F - test_insert_newline_at_start_of_line/data:1')
+  check_eq(Editor_state.lines[2].data, 'abc', 'F - test_insert_newline_at_start_of_line/data:2')
+end
+
+function test_insert_from_clipboard()
+  io.write('\ntest_insert_from_clipboard')
+  -- display a few lines
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=2}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_insert_from_clipboard/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_insert_from_clipboard/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_insert_from_clipboard/baseline/screen:3')
+  -- paste some text including a newline, check that new line is created
+  App.clipboard = 'xy\nz'
+  edit.run_after_keychord(Editor_state, 'C-v')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_insert_from_clipboard/screen_top')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_insert_from_clipboard/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 2, 'F - test_insert_from_clipboard/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'axy', 'F - test_insert_from_clipboard/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'zbc', 'F - test_insert_from_clipboard/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_insert_from_clipboard/screen:3')
+end
+
+function test_move_cursor_using_mouse()
+  io.write('\ntest_move_cursor_using_mouse')
+  App.screen.init{width=50, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'xyz'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)  -- populate line_cache.starty for each line Editor_state.line_cache
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+8,Editor_state.top+5, 1)
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_move_cursor_using_mouse/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 2, 'F - test_move_cursor_using_mouse/cursor:pos')
+end
+
+function test_pagedown()
+  io.write('\ntest_pagedown')
+  App.screen.init{width=120, height=45}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- initially the first two lines are displayed
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_pagedown/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_pagedown/baseline/screen:2')
+  -- after pagedown the bottom line becomes the top
+  edit.run_after_keychord(Editor_state, 'pagedown')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_pagedown/screen_top')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_pagedown/cursor')
+  y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_pagedown/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_pagedown/screen:2')
+end
+
+function test_pagedown_can_start_from_middle_of_long_wrapping_line()
+  io.write('\ntest_pagedown_can_start_from_middle_of_long_wrapping_line')
+  -- draw a few lines starting from a very long wrapping line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def ghi jkl mno pqr stu vwx yza bcd efg hij', 'XYZ'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=2}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/baseline/screen:3')
+  -- after pagedown we scroll down the very long wrapping line
+  edit.run_after_keychord(Editor_state, 'pagedown')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen_top:line')
+  check_eq(Editor_state.screen_top1.pos, 9, 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen_top:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'ghi ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen:3')
+end
+
+function test_pagedown_never_moves_up()
+  io.write('\ntest_pagedown_never_moves_up')
+  -- draw the final screen line of a wrapping line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=9}
+  Editor_state.screen_top1 = {line=1, pos=9}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  -- pagedown makes no change
+  edit.run_after_keychord(Editor_state, 'pagedown')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_pagedown_never_moves_up/screen_top:line')
+  check_eq(Editor_state.screen_top1.pos, 9, 'F - test_pagedown_never_moves_up/screen_top:pos')
+end
+
+function test_down_arrow_moves_cursor()
+  io.write('\ntest_down_arrow_moves_cursor')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- initially the first three lines are displayed
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_down_arrow_moves_cursor/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_down_arrow_moves_cursor/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_down_arrow_moves_cursor/baseline/screen:3')
+  -- after hitting the down arrow, the cursor moves down by 1 line
+  edit.run_after_keychord(Editor_state, 'down')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_down_arrow_moves_cursor/screen_top')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_down_arrow_moves_cursor/cursor')
+  -- the screen is unchanged
+  y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_down_arrow_moves_cursor/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_down_arrow_moves_cursor/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_down_arrow_moves_cursor/screen:3')
+end
+
+function test_down_arrow_scrolls_down_by_one_line()
+  io.write('\ntest_down_arrow_scrolls_down_by_one_line')
+  -- display the first three lines with the cursor on the bottom line
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=3, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_down_arrow_scrolls_down_by_one_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_down_arrow_scrolls_down_by_one_line/baseline/screen:3')
+  -- after hitting the down arrow the screen scrolls down by one line
+  edit.run_after_keychord(Editor_state, 'down')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_down_arrow_scrolls_down_by_one_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 4, 'F - test_down_arrow_scrolls_down_by_one_line/cursor')
+  y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_down_arrow_scrolls_down_by_one_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_down_arrow_scrolls_down_by_one_line/screen:3')
+end
+
+function test_down_arrow_scrolls_down_by_one_screen_line()
+  io.write('\ntest_down_arrow_scrolls_down_by_one_screen_line')
+  -- display the first three lines with the cursor on the bottom line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=3, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_down_arrow_scrolls_down_by_one_screen_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_screen_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi ', 'F - test_down_arrow_scrolls_down_by_one_screen_line/baseline/screen:3')  -- line wrapping includes trailing whitespace
+  -- after hitting the down arrow the screen scrolls down by one line
+  edit.run_after_keychord(Editor_state, 'down')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_down_arrow_scrolls_down_by_one_screen_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_down_arrow_scrolls_down_by_one_screen_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 5, 'F - test_down_arrow_scrolls_down_by_one_screen_line/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_screen_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi ', 'F - test_down_arrow_scrolls_down_by_one_screen_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_down_arrow_scrolls_down_by_one_screen_line/screen:3')
+end
+
+function test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word()
+  io.write('\ntest_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word')
+  -- display the first three lines with the cursor on the bottom line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghijkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=3, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghij', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/baseline/screen:3')
+  -- after hitting the down arrow the screen scrolls down by one line
+  edit.run_after_keychord(Editor_state, 'down')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 5, 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghij', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'kl', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/screen:3')
+end
+
+function test_page_down_followed_by_down_arrow_does_not_scroll_screen_up()
+  io.write('\ntest_page_down_followed_by_down_arrow_does_not_scroll_screen_up')
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghijkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=3, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghij', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline/screen:3')
+  -- after hitting pagedown the screen scrolls down to start of a long line
+  edit.run_after_keychord(Editor_state, 'pagedown')
+  check_eq(Editor_state.screen_top1.line, 3, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline2/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline2/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline2/cursor:pos')
+  -- after hitting down arrow the screen doesn't scroll down further, and certainly doesn't scroll up
+  edit.run_after_keychord(Editor_state, 'down')
+  check_eq(Editor_state.screen_top1.line, 3, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 5, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'ghij', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'kl', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/screen:3')
+end
+
+function test_up_arrow_moves_cursor()
+  io.write('\ntest_up_arrow_moves_cursor')
+  -- display the first 3 lines with the cursor on the bottom line
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=3, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_up_arrow_moves_cursor/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_up_arrow_moves_cursor/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_up_arrow_moves_cursor/baseline/screen:3')
+  -- after hitting the up arrow the cursor moves up by 1 line
+  edit.run_after_keychord(Editor_state, 'up')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_up_arrow_moves_cursor/screen_top')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_up_arrow_moves_cursor/cursor')
+  -- the screen is unchanged
+  y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_up_arrow_moves_cursor/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_up_arrow_moves_cursor/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_up_arrow_moves_cursor/screen:3')
+end
+
+function test_up_arrow_scrolls_up_by_one_line()
+  io.write('\ntest_up_arrow_scrolls_up_by_one_line')
+  -- display the lines 2/3/4 with the cursor on line 2
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=2, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_by_one_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_by_one_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_by_one_line/baseline/screen:3')
+  -- after hitting the up arrow the screen scrolls up by one line
+  edit.run_after_keychord(Editor_state, 'up')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_up_arrow_scrolls_up_by_one_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_up_arrow_scrolls_up_by_one_line/cursor')
+  y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_up_arrow_scrolls_up_by_one_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_by_one_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_by_one_line/screen:3')
+end
+
+function test_up_arrow_scrolls_up_by_one_screen_line()
+  io.write('\ntest_up_arrow_scrolls_up_by_one_screen_line')
+  -- display lines starting from second screen line of a line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=3, pos=6}
+  Editor_state.screen_top1 = {line=3, pos=5}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_by_one_screen_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_up_arrow_scrolls_up_by_one_screen_line/baseline/screen:2')
+  -- after hitting the up arrow the screen scrolls up to first screen line
+  edit.run_after_keychord(Editor_state, 'up')
+  y = Editor_state.top
+  App.screen.check(y, 'ghi ', 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen:3')
+  check_eq(Editor_state.screen_top1.line, 3, 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen_top')
+  check_eq(Editor_state.screen_top1.pos, 1, 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_up_arrow_scrolls_up_by_one_screen_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_up_arrow_scrolls_up_by_one_screen_line/cursor:pos')
+end
+
+function test_up_arrow_scrolls_up_to_final_screen_line()
+  io.write('\ntest_up_arrow_scrolls_up_to_final_screen_line')
+  -- display lines starting just after a long line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def', 'ghi', 'jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=2, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_to_final_screen_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_to_final_screen_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_up_arrow_scrolls_up_to_final_screen_line/baseline/screen:3')
+  -- after hitting the up arrow the screen scrolls up to final screen line of previous line
+  edit.run_after_keychord(Editor_state, 'up')
+  y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen:3')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen_top')
+  check_eq(Editor_state.screen_top1.pos, 5, 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_up_arrow_scrolls_up_to_final_screen_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 5, 'F - test_up_arrow_scrolls_up_to_final_screen_line/cursor:pos')
+end
+
+function test_up_arrow_scrolls_up_to_empty_line()
+  io.write('\ntest_up_arrow_scrolls_up_to_empty_line')
+  -- display a screenful of text with an empty line just above it outside the screen
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'', 'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=2, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_up_arrow_scrolls_up_to_empty_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_to_empty_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_to_empty_line/baseline/screen:3')
+  -- after hitting the up arrow the screen scrolls up by one line
+  edit.run_after_keychord(Editor_state, 'up')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_up_arrow_scrolls_up_to_empty_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_up_arrow_scrolls_up_to_empty_line/cursor')
+  y = Editor_state.top
+  -- empty first line
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'abc', 'F - test_up_arrow_scrolls_up_to_empty_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_to_empty_line/screen:3')
+end
+
+function test_pageup()
+  io.write('\ntest_pageup')
+  App.screen.init{width=120, height=45}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=2, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- initially the last two lines are displayed
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_pageup/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_pageup/baseline/screen:2')
+  -- after pageup the cursor goes to first line
+  edit.run_after_keychord(Editor_state, 'pageup')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_pageup/screen_top')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_pageup/cursor')
+  y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_pageup/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_pageup/screen:2')
+end
+
+function test_pageup_scrolls_up_by_screen_line()
+  io.write('\ntest_pageup_scrolls_up_by_screen_line')
+  -- display the first three lines with the cursor on the bottom line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def', 'ghi', 'jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=2, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'ghi', 'F - test_pageup_scrolls_up_by_screen_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_pageup_scrolls_up_by_screen_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_pageup_scrolls_up_by_screen_line/baseline/screen:3')  -- line wrapping includes trailing whitespace
+  -- after hitting the page-up key the screen scrolls up to top
+  edit.run_after_keychord(Editor_state, 'pageup')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_pageup_scrolls_up_by_screen_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_pageup_scrolls_up_by_screen_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_pageup_scrolls_up_by_screen_line/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'abc ', 'F - test_pageup_scrolls_up_by_screen_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_pageup_scrolls_up_by_screen_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_pageup_scrolls_up_by_screen_line/screen:3')
+end
+
+function test_pageup_scrolls_up_from_middle_screen_line()
+  io.write('\ntest_pageup_scrolls_up_from_middle_screen_line')
+  -- display a few lines starting from the middle of a line (Editor_state.cursor1.pos > 1)
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def', 'ghi jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=5}
+  Editor_state.screen_top1 = {line=2, pos=5}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'jkl', 'F - test_pageup_scrolls_up_from_middle_screen_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_pageup_scrolls_up_from_middle_screen_line/baseline/screen:3')  -- line wrapping includes trailing whitespace
+  -- after hitting the page-up key the screen scrolls up to top
+  edit.run_after_keychord(Editor_state, 'pageup')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_pageup_scrolls_up_from_middle_screen_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_pageup_scrolls_up_from_middle_screen_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_pageup_scrolls_up_from_middle_screen_line/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'abc ', 'F - test_pageup_scrolls_up_from_middle_screen_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_pageup_scrolls_up_from_middle_screen_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi ', 'F - test_pageup_scrolls_up_from_middle_screen_line/screen:3')
+end
+
+function test_enter_on_bottom_line_scrolls_down()
+  io.write('\ntest_enter_on_bottom_line_scrolls_down')
+  -- display a few lines with cursor on bottom line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=3, pos=2}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_enter_on_bottom_line_scrolls_down/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_enter_on_bottom_line_scrolls_down/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_enter_on_bottom_line_scrolls_down/baseline/screen:3')
+  -- after hitting the enter key the screen scrolls down
+  edit.run_after_keychord(Editor_state, 'return')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_enter_on_bottom_line_scrolls_down/screen_top')
+  check_eq(Editor_state.cursor1.line, 4, 'F - test_enter_on_bottom_line_scrolls_down/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_enter_on_bottom_line_scrolls_down/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_enter_on_bottom_line_scrolls_down/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'g', 'F - test_enter_on_bottom_line_scrolls_down/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'hi', 'F - test_enter_on_bottom_line_scrolls_down/screen:3')
+end
+
+function test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom()
+  io.write('\ntest_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom')
+  -- display just the bottom line on screen
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=4, pos=2}
+  Editor_state.screen_top1 = {line=4, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'jkl', 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/baseline/screen:1')
+  -- after hitting the enter key the screen does not scroll down
+  edit.run_after_keychord(Editor_state, 'return')
+  check_eq(Editor_state.screen_top1.line, 4, 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen_top')
+  check_eq(Editor_state.cursor1.line, 5, 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'j', 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'kl', 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen:2')
+end
+
+function test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom()
+  io.write('\ntest_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom')
+  -- display just an empty bottom line on screen
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=2, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  -- after hitting the inserting_text key the screen does not scroll down
+  edit.run_after_textinput(Editor_state, 'a')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen_top')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 2, 'F - test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom/cursor:pos')
+  local y = Editor_state.top
+  App.screen.check(y, 'a', 'F - test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen:1')
+end
+
+function test_typing_on_bottom_line_scrolls_down()
+  io.write('\ntest_typing_on_bottom_line_scrolls_down')
+  -- display a few lines with cursor on bottom line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=3, pos=4}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_typing_on_bottom_line_scrolls_down/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_typing_on_bottom_line_scrolls_down/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_typing_on_bottom_line_scrolls_down/baseline/screen:3')
+  -- after typing something the line wraps and the screen scrolls down
+  edit.run_after_textinput(Editor_state, 'j')
+  edit.run_after_textinput(Editor_state, 'k')
+  edit.run_after_textinput(Editor_state, 'l')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_typing_on_bottom_line_scrolls_down/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_typing_on_bottom_line_scrolls_down/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 7, 'F - test_typing_on_bottom_line_scrolls_down/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_typing_on_bottom_line_scrolls_down/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghij', 'F - test_typing_on_bottom_line_scrolls_down/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'kl', 'F - test_typing_on_bottom_line_scrolls_down/screen:3')
+end
+
+function test_left_arrow_scrolls_up_in_wrapped_line()
+  io.write('\ntest_left_arrow_scrolls_up_in_wrapped_line')
+  -- display lines starting from second screen line of a line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.screen_top1 = {line=3, pos=5}
+  Editor_state.screen_bottom1 = {}
+  -- cursor is at top of screen
+  Editor_state.cursor1 = {line=3, pos=5}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'jkl', 'F - test_left_arrow_scrolls_up_in_wrapped_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_left_arrow_scrolls_up_in_wrapped_line/baseline/screen:2')
+  -- after hitting the left arrow the screen scrolls up to first screen line
+  edit.run_after_keychord(Editor_state, 'left')
+  y = Editor_state.top
+  App.screen.check(y, 'ghi ', 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen:3')
+  check_eq(Editor_state.screen_top1.line, 3, 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen_top')
+  check_eq(Editor_state.screen_top1.pos, 1, 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_left_arrow_scrolls_up_in_wrapped_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 4, 'F - test_left_arrow_scrolls_up_in_wrapped_line/cursor:pos')
+end
+
+function test_right_arrow_scrolls_down_in_wrapped_line()
+  io.write('\ntest_right_arrow_scrolls_down_in_wrapped_line')
+  -- display the first three lines with the cursor on the bottom line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- cursor is at bottom right of screen
+  Editor_state.cursor1 = {line=3, pos=5}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_right_arrow_scrolls_down_in_wrapped_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_right_arrow_scrolls_down_in_wrapped_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi ', 'F - test_right_arrow_scrolls_down_in_wrapped_line/baseline/screen:3')  -- line wrapping includes trailing whitespace
+  -- after hitting the right arrow the screen scrolls down by one line
+  edit.run_after_keychord(Editor_state, 'right')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_right_arrow_scrolls_down_in_wrapped_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_right_arrow_scrolls_down_in_wrapped_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 6, 'F - test_right_arrow_scrolls_down_in_wrapped_line/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_right_arrow_scrolls_down_in_wrapped_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi ', 'F - test_right_arrow_scrolls_down_in_wrapped_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_right_arrow_scrolls_down_in_wrapped_line/screen:3')
+end
+
+function test_home_scrolls_up_in_wrapped_line()
+  io.write('\ntest_home_scrolls_up_in_wrapped_line')
+  -- display lines starting from second screen line of a line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.screen_top1 = {line=3, pos=5}
+  Editor_state.screen_bottom1 = {}
+  -- cursor is at top of screen
+  Editor_state.cursor1 = {line=3, pos=5}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'jkl', 'F - test_home_scrolls_up_in_wrapped_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_home_scrolls_up_in_wrapped_line/baseline/screen:2')
+  -- after hitting home the screen scrolls up to first screen line
+  edit.run_after_keychord(Editor_state, 'home')
+  y = Editor_state.top
+  App.screen.check(y, 'ghi ', 'F - test_home_scrolls_up_in_wrapped_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_home_scrolls_up_in_wrapped_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_home_scrolls_up_in_wrapped_line/screen:3')
+  check_eq(Editor_state.screen_top1.line, 3, 'F - test_home_scrolls_up_in_wrapped_line/screen_top')
+  check_eq(Editor_state.screen_top1.pos, 1, 'F - test_home_scrolls_up_in_wrapped_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_home_scrolls_up_in_wrapped_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_home_scrolls_up_in_wrapped_line/cursor:pos')
+end
+
+function test_end_scrolls_down_in_wrapped_line()
+  io.write('\ntest_end_scrolls_down_in_wrapped_line')
+  -- display the first three lines with the cursor on the bottom line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- cursor is at bottom right of screen
+  Editor_state.cursor1 = {line=3, pos=5}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_end_scrolls_down_in_wrapped_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_end_scrolls_down_in_wrapped_line/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi ', 'F - test_end_scrolls_down_in_wrapped_line/baseline/screen:3')  -- line wrapping includes trailing whitespace
+  -- after hitting end the screen scrolls down by one line
+  edit.run_after_keychord(Editor_state, 'end')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_end_scrolls_down_in_wrapped_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_end_scrolls_down_in_wrapped_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 8, 'F - test_end_scrolls_down_in_wrapped_line/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_end_scrolls_down_in_wrapped_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi ', 'F - test_end_scrolls_down_in_wrapped_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_end_scrolls_down_in_wrapped_line/screen:3')
+end
+
+function test_position_cursor_on_recently_edited_wrapping_line()
+  -- draw a line wrapping over 2 screen lines
+  io.write('\ntest_position_cursor_on_recently_edited_wrapping_line')
+  App.screen.init{width=100, height=200}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc def ghi jkl mno pqr ', 'xyz'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=25}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc def ghi ', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline1/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl mno pqr ', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline1/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'xyz', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline1/screen:3')
+  -- add to the line until it's wrapping over 3 screen lines
+  edit.run_after_textinput(Editor_state, 's')
+  edit.run_after_textinput(Editor_state, 't')
+  edit.run_after_textinput(Editor_state, 'u')
+  check_eq(Editor_state.cursor1.pos, 28, 'F - test_position_cursor_on_recently_edited_wrapping_line/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'abc def ghi ', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline2/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl mno pqr ', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline2/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'stu', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline2/screen:3')
+  -- try to move the cursor earlier in the third screen line by clicking the mouse
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+8,Editor_state.top+Editor_state.line_height*2+5, 1)
+  -- cursor should move
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_position_cursor_on_recently_edited_wrapping_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 26, 'F - test_position_cursor_on_recently_edited_wrapping_line/cursor:pos')
+end
+
+function test_backspace_can_scroll_up()
+  io.write('\ntest_backspace_can_scroll_up')
+  -- display the lines 2/3/4 with the cursor on line 2
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  Editor_state.screen_top1 = {line=2, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'def', 'F - test_backspace_can_scroll_up/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_backspace_can_scroll_up/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_backspace_can_scroll_up/baseline/screen:3')
+  -- after hitting backspace the screen scrolls up by one line
+  edit.run_after_keychord(Editor_state, 'backspace')
+  check_eq(Editor_state.screen_top1.line, 1, 'F - test_backspace_can_scroll_up/screen_top')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_backspace_can_scroll_up/cursor')
+  y = Editor_state.top
+  App.screen.check(y, 'abcdef', 'F - test_backspace_can_scroll_up/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'ghi', 'F - test_backspace_can_scroll_up/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'jkl', 'F - test_backspace_can_scroll_up/screen:3')
+end
+
+function test_backspace_can_scroll_up_screen_line()
+  io.write('\ntest_backspace_can_scroll_up_screen_line')
+  -- display lines starting from second screen line of a line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=3, pos=5}
+  Editor_state.screen_top1 = {line=3, pos=5}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'jkl', 'F - test_backspace_can_scroll_up_screen_line/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_backspace_can_scroll_up_screen_line/baseline/screen:2')
+  -- after hitting backspace the screen scrolls up by one screen line
+  edit.run_after_keychord(Editor_state, 'backspace')
+  y = Editor_state.top
+  App.screen.check(y, 'ghij', 'F - test_backspace_can_scroll_up_screen_line/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'kl', 'F - test_backspace_can_scroll_up_screen_line/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'mno', 'F - test_backspace_can_scroll_up_screen_line/screen:3')
+  check_eq(Editor_state.screen_top1.line, 3, 'F - test_backspace_can_scroll_up_screen_line/screen_top')
+  check_eq(Editor_state.screen_top1.pos, 1, 'F - test_backspace_can_scroll_up_screen_line/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_backspace_can_scroll_up_screen_line/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 4, 'F - test_backspace_can_scroll_up_screen_line/cursor:pos')
+end
+
+function test_backspace_past_line_boundary()
+  io.write('\ntest_backspace_past_line_boundary')
+  -- position cursor at start of a (non-first) line
+  App.screen.init{width=Editor_state.left+30, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=1}
+  -- backspace joins with previous line
+  edit.run_after_keychord(Editor_state, 'backspace')
+  check_eq(Editor_state.lines[1].data, 'abcdef', "F - test_backspace_past_line_boundary")
+end
+
+function test_undo_insert_text()
+  io.write('\ntest_undo_insert_text')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'xyz'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=4}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- insert a character
+  edit.draw(Editor_state)
+  edit.run_after_textinput(Editor_state, 'g')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_undo_insert_text/baseline/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 5, 'F - test_undo_insert_text/baseline/cursor:pos')
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_undo_insert_text/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'defg', 'F - test_undo_insert_text/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'xyz', 'F - test_undo_insert_text/baseline/screen:3')
+  -- undo
+  edit.run_after_keychord(Editor_state, 'C-z')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_undo_insert_text/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 4, 'F - test_undo_insert_text/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_undo_insert_text/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_undo_insert_text/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'xyz', 'F - test_undo_insert_text/screen:3')
+end
+
+function test_undo_delete_text()
+  io.write('\ntest_undo_delete_text')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'defg', 'xyz'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=2, pos=5}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  -- delete a character
+  edit.run_after_keychord(Editor_state, 'backspace')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_undo_delete_text/baseline/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 4, 'F - test_undo_delete_text/baseline/cursor:pos')
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_undo_delete_text/baseline/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'def', 'F - test_undo_delete_text/baseline/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'xyz', 'F - test_undo_delete_text/baseline/screen:3')
+  -- undo
+--?   -- after undo, the backspaced key is selected
+  edit.run_after_keychord(Editor_state, 'C-z')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_undo_delete_text/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 5, 'F - test_undo_delete_text/cursor:pos')
+  y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_undo_delete_text/screen:1')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'defg', 'F - test_undo_delete_text/screen:2')
+  y = y + Editor_state.line_height
+  App.screen.check(y, 'xyz', 'F - test_undo_delete_text/screen:3')
+end
+
+function test_search()
+  io.write('\ntest_search')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi', 'deg'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  -- search for a string
+  edit.run_after_keychord(Editor_state, 'C-f')
+  edit.run_after_textinput(Editor_state, 'd')
+  edit.run_after_keychord(Editor_state, 'return')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_search/1/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_search/1/cursor:pos')
+  -- reset cursor
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  -- search for second occurrence
+  edit.run_after_keychord(Editor_state, 'C-f')
+  edit.run_after_textinput(Editor_state, 'de')
+  edit.run_after_keychord(Editor_state, 'down')
+  edit.run_after_keychord(Editor_state, 'return')
+  check_eq(Editor_state.cursor1.line, 4, 'F - test_search/2/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_search/2/cursor:pos')
+end
+
+function test_search_upwards()
+  io.write('\ntest_search_upwards')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc abd'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=2}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  -- search for a string
+  edit.run_after_keychord(Editor_state, 'C-f')
+  edit.run_after_textinput(Editor_state, 'a')
+  -- search for previous occurrence
+  edit.run_after_keychord(Editor_state, 'up')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_search_upwards/2/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_search_upwards/2/cursor:pos')
+end
+
+function test_search_wrap()
+  io.write('\ntest_search_wrap')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=3}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  -- search for a string
+  edit.run_after_keychord(Editor_state, 'C-f')
+  edit.run_after_textinput(Editor_state, 'a')
+  edit.run_after_keychord(Editor_state, 'return')
+  -- cursor wraps
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_search_wrap/1/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 1, 'F - test_search_wrap/1/cursor:pos')
+end
+
+function test_search_wrap_upwards()
+  io.write('\ntest_search_wrap_upwards')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc abd'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  edit.draw(Editor_state)
+  -- search upwards for a string
+  edit.run_after_keychord(Editor_state, 'C-f')
+  edit.run_after_textinput(Editor_state, 'a')
+  edit.run_after_keychord(Editor_state, 'up')
+  -- cursor wraps
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_search_wrap_upwards/1/cursor:line')
+  check_eq(Editor_state.cursor1.pos, 5, 'F - test_search_wrap_upwards/1/cursor:pos')
+end
diff --git a/source_undo.lua b/source_undo.lua
new file mode 100644
index 0000000..0aa6755
--- /dev/null
+++ b/source_undo.lua
@@ -0,0 +1,110 @@
+-- undo/redo by managing the sequence of events in the current session
+-- based on https://github.com/akkartik/mu1/blob/master/edit/012-editor-undo.mu
+
+-- Incredibly inefficient; we make a copy of lines on every single keystroke.
+-- The hope here is that we're either editing small files or just reading large files.
+-- TODO: highlight stuff inserted by any undo/redo operation
+-- TODO: coalesce multiple similar operations
+
+function record_undo_event(State, data)
+  State.history[State.next_history] = data
+  State.next_history = State.next_history+1
+  for i=State.next_history,#State.history do
+    State.history[i] = nil
+  end
+end
+
+function undo_event(State)
+  if State.next_history > 1 then
+--?     print('moving to history', State.next_history-1)
+    State.next_history = State.next_history-1
+    local result = State.history[State.next_history]
+    return result
+  end
+end
+
+function redo_event(State)
+  if State.next_history <= #State.history then
+--?     print('restoring history', State.next_history+1)
+    local result = State.history[State.next_history]
+    State.next_history = State.next_history+1
+    return result
+  end
+end
+
+-- Copy all relevant global state.
+-- Make copies of objects; the rest of the app may mutate them in place, but undo requires immutable histories.
+function snapshot(State, s,e)
+  -- Snapshot everything by default, but subset if requested.
+  assert(s)
+  if e == nil then
+    e = s
+  end
+  assert(#State.lines > 0)
+  if s < 1 then s = 1 end
+  if s > #State.lines then s = #State.lines end
+  if e < 1 then e = 1 end
+  if e > #State.lines then e = #State.lines end
+  -- compare with App.initialize_globals
+  local event = {
+    screen_top=deepcopy(State.screen_top1),
+    selection=deepcopy(State.selection1),
+    cursor=deepcopy(State.cursor1),
+    lines={},
+    start_line=s,
+    end_line=e,
+    -- no filename; undo history is cleared when filename changes
+  }
+  -- deep copy lines without cached stuff like text fragments
+  for i=s,e do
+    local line = State.lines[i]
+    table.insert(event.lines, {data=line.data, dataB=line.dataB})
+  end
+  return event
+end
+
+function patch(lines, from, to)
+--?   if #from.lines == 1 and #to.lines == 1 then
+--?     assert(from.start_line == from.end_line)
+--?     assert(to.start_line == to.end_line)
+--?     assert(from.start_line == to.start_line)
+--?     lines[from.start_line] = to.lines[1]
+--?     return
+--?   end
+  assert(from.start_line == to.start_line)
+  for i=from.end_line,from.start_line,-1 do
+    table.remove(lines, i)
+  end
+  assert(#to.lines == to.end_line-to.start_line+1)
+  for i=1,#to.lines do
+    table.insert(lines, to.start_line+i-1, to.lines[i])
+  end
+end
+
+function patch_placeholders(line_cache, from, to)
+  assert(from.start_line == to.start_line)
+  for i=from.end_line,from.start_line,-1 do
+    table.remove(line_cache, i)
+  end
+  assert(#to.lines == to.end_line-to.start_line+1)
+  for i=1,#to.lines do
+    table.insert(line_cache, to.start_line+i-1, {})
+  end
+end
+
+-- https://stackoverflow.com/questions/640642/how-do-you-copy-a-lua-table-by-value/26367080#26367080
+function deepcopy(obj, seen)
+  if type(obj) ~= 'table' then return obj end
+  if seen and seen[obj] then return seen[obj] end
+  local s = seen or {}
+  local result = setmetatable({}, getmetatable(obj))
+  s[obj] = result
+  for k,v in pairs(obj) do
+    result[deepcopy(k, s)] = deepcopy(v, s)
+  end
+  return result
+end
+
+function minmax(a, b)
+  return math.min(a,b), math.max(a,b)
+end
diff --git a/text.lua b/text.lua
index 2e3aa32..c2c633b 100644
--- a/text.lua
+++ b/text.lua
@@ -1,11 +1,6 @@
 -- text editor, particularly text drawing, horizontal wrap, vertical scrolling
 Text = {}
 
-require 'search'
-require 'select'
-require 'undo'
-require 'text_tests'
-
 -- draw a line starting from startpos to screen at y between State.left and State.right
 -- return the final y, and position of start of final screen line drawn
 function Text.draw(State, line_index, y, startpos)