about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorKartik K. Agaram <vc@akkartik.com>2022-09-05 12:47:27 -0700
committerKartik K. Agaram <vc@akkartik.com>2022-09-05 12:47:27 -0700
commite0f750913fa653c03a666203e04c59e847e73bff (patch)
tree037a1062d40b54331ee0cbe797b5bd8ede551180
parentfc98407c930f1ba48a48657031bae06991666449 (diff)
parentfdb35ce12bd8b2e30522ada07ec756a626d01a09 (diff)
downloadview.love-e0f750913fa653c03a666203e04c59e847e73bff.tar.gz
Merge lines.love
-rw-r--r--app.lua28
-rw-r--r--commands.lua2
-rw-r--r--drawing.lua745
-rw-r--r--drawing_tests.lua785
-rw-r--r--edit.lua8
-rw-r--r--file.lua3
-rw-r--r--geom.lua168
-rw-r--r--help.lua151
-rw-r--r--icons.lua59
-rw-r--r--main.lua9
-rw-r--r--source.lua6
-rw-r--r--source_edit.lua178
-rw-r--r--source_file.lua156
-rw-r--r--source_text.lua197
-rw-r--r--source_text_tests.lua60
-rw-r--r--source_undo.lua16
-rw-r--r--text.lua22
17 files changed, 2474 insertions, 119 deletions
diff --git a/app.lua b/app.lua
index 2135fb0..69507ec 100644
--- a/app.lua
+++ b/app.lua
@@ -383,8 +383,32 @@ function App.disable_tests()
   App.newText = love.graphics.newText
   App.screen.draw = love.graphics.draw
   App.width = function(text) return text:getWidth() end
-  App.open_for_reading = function(filename) return io.open(filename, 'r') end
-  App.open_for_writing = function(filename) return io.open(filename, 'w') end
+  if Current_app == nil or Current_app == 'run' then
+    App.open_for_reading = function(filename) return io.open(filename, 'r') end
+    App.open_for_writing = function(filename) return io.open(filename, 'w') end
+  elseif Current_app == 'source' then
+    -- HACK: source editor requires a couple of different foundational definitions
+    App.open_for_reading =
+        function(filename)
+          local result = love.filesystem.newFile(filename)
+          local ok, err = result:open('r')
+          if ok then
+            return result
+          else
+            return ok, err
+          end
+        end
+    App.open_for_writing =
+        function(filename)
+          local result = love.filesystem.newFile(filename)
+          local ok, err = result:open('w')
+          if ok then
+            return result
+          else
+            return ok, err
+          end
+        end
+  end
   App.getTime = love.timer.getTime
   App.getClipboardText = love.system.getClipboardText
   App.setClipboardText = love.system.setClipboardText
diff --git a/commands.lua b/commands.lua
index 037205f..730fffc 100644
--- a/commands.lua
+++ b/commands.lua
@@ -28,7 +28,7 @@ function source.draw_menu_bar()
     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+i: 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
diff --git a/drawing.lua b/drawing.lua
new file mode 100644
index 0000000..0343f85
--- /dev/null
+++ b/drawing.lua
@@ -0,0 +1,745 @@
+-- primitives for editing drawings
+Drawing = {}
+require 'drawing_tests'
+
+-- All drawings span 100% of some conceptual 'page width' and divide it up
+-- into 256 parts.
+function Drawing.draw(State, line_index, y)
+  local line = State.lines[line_index]
+  local line_cache = State.line_cache[line_index]
+  line_cache.starty = y
+  local pmx,pmy = App.mouse_x(), App.mouse_y()
+  if pmx < State.right and pmy > line_cache.starty and pmy < line_cache.starty+Drawing.pixels(line.h, State.width) then
+    App.color(Icon_color)
+    love.graphics.rectangle('line', State.left,line_cache.starty, State.width,Drawing.pixels(line.h, State.width))
+    if icon[State.current_drawing_mode] then
+      icon[State.current_drawing_mode](State.right-22, line_cache.starty+4)
+    else
+      icon[State.previous_drawing_mode](State.right-22, line_cache.starty+4)
+    end
+
+    if App.mouse_down(1) and love.keyboard.isDown('h') then
+      draw_help_with_mouse_pressed(State, line_index)
+      return
+    end
+  end
+
+  if line.show_help then
+    draw_help_without_mouse_pressed(State, line_index)
+    return
+  end
+
+  local mx = Drawing.coord(pmx-State.left, State.width)
+  local my = Drawing.coord(pmy-line_cache.starty, State.width)
+
+  for _,shape in ipairs(line.shapes) do
+    assert(shape)
+    if geom.on_shape(mx,my, line, shape) then
+      App.color(Focus_stroke_color)
+    else
+      App.color(Stroke_color)
+    end
+    Drawing.draw_shape(line, shape, line_cache.starty, State.left,State.right)
+  end
+
+  local function px(x) return Drawing.pixels(x, State.width)+State.left end
+  local function py(y) return Drawing.pixels(y, State.width)+line_cache.starty end
+  for i,p in ipairs(line.points) do
+    if p.deleted == nil then
+      if Drawing.near(p, mx,my, State.width) then
+        App.color(Focus_stroke_color)
+        love.graphics.circle('line', px(p.x),py(p.y), Same_point_distance)
+      else
+        App.color(Stroke_color)
+        love.graphics.circle('fill', px(p.x),py(p.y), 2)
+      end
+      if p.name then
+        -- TODO: clip
+        local x,y = px(p.x)+5, py(p.y)+5
+        love.graphics.print(p.name, x,y)
+        if State.current_drawing_mode == 'name' and i == line.pending.target_point then
+          -- create a faint red box for the name
+          App.color(Current_name_background_color)
+          local name_text
+          -- TODO: avoid computing name width on every repaint
+          if p.name == '' then
+            name_text = State.em
+          else
+            name_text = App.newText(love.graphics.getFont(), p.name)
+          end
+          love.graphics.rectangle('fill', x,y, App.width(name_text), State.line_height)
+        end
+      end
+    end
+  end
+  App.color(Current_stroke_color)
+  Drawing.draw_pending_shape(line, line_cache.starty, State.left,State.right)
+end
+
+function Drawing.draw_shape(drawing, shape, top, left,right)
+  local width = right-left
+  local function px(x) return Drawing.pixels(x, width)+left end
+  local function py(y) return Drawing.pixels(y, width)+top end
+  if shape.mode == 'freehand' then
+    local prev = nil
+    for _,point in ipairs(shape.points) do
+      if prev then
+        love.graphics.line(px(prev.x),py(prev.y), px(point.x),py(point.y))
+      end
+      prev = point
+    end
+  elseif shape.mode == 'line' or shape.mode == 'manhattan' then
+    local p1 = drawing.points[shape.p1]
+    local p2 = drawing.points[shape.p2]
+    love.graphics.line(px(p1.x),py(p1.y), px(p2.x),py(p2.y))
+  elseif shape.mode == 'polygon' or shape.mode == 'rectangle' or shape.mode == 'square' then
+    local prev = nil
+    for _,point in ipairs(shape.vertices) do
+      local curr = drawing.points[point]
+      if prev then
+        love.graphics.line(px(prev.x),py(prev.y), px(curr.x),py(curr.y))
+      end
+      prev = curr
+    end
+    -- close the loop
+    local curr = drawing.points[shape.vertices[1]]
+    love.graphics.line(px(prev.x),py(prev.y), px(curr.x),py(curr.y))
+  elseif shape.mode == 'circle' then
+    -- TODO: clip
+    local center = drawing.points[shape.center]
+    love.graphics.circle('line', px(center.x),py(center.y), Drawing.pixels(shape.radius, width))
+  elseif shape.mode == 'arc' then
+    local center = drawing.points[shape.center]
+    love.graphics.arc('line', 'open', px(center.x),py(center.y), Drawing.pixels(shape.radius, width), shape.start_angle, shape.end_angle, 360)
+  elseif shape.mode == 'deleted' then
+    -- ignore
+  else
+    print(shape.mode)
+    assert(false)
+  end
+end
+
+function Drawing.draw_pending_shape(drawing, top, left,right)
+  local width = right-left
+  local pmx,pmy = App.mouse_x(), App.mouse_y()
+  local function px(x) return Drawing.pixels(x, width)+left end
+  local function py(y) return Drawing.pixels(y, width)+top end
+  local mx = Drawing.coord(pmx-left, width)
+  local my = Drawing.coord(pmy-top, width)
+  -- recreate pixels from coords to precisely mimic how the drawing will look
+  -- after mouse_released
+  pmx,pmy = px(mx), py(my)
+  local shape = drawing.pending
+  if shape.mode == nil then
+    -- nothing pending
+  elseif shape.mode == 'freehand' then
+    local shape_copy = deepcopy(shape)
+    Drawing.smoothen(shape_copy)
+    Drawing.draw_shape(drawing, shape_copy, top, left,right)
+  elseif shape.mode == 'line' then
+    if mx < 0 or mx >= 256 or my < 0 or my >= drawing.h then
+      return
+    end
+    local p1 = drawing.points[shape.p1]
+    love.graphics.line(px(p1.x),py(p1.y), pmx,pmy)
+  elseif shape.mode == 'manhattan' then
+    if mx < 0 or mx >= 256 or my < 0 or my >= drawing.h then
+      return
+    end
+    local p1 = drawing.points[shape.p1]
+    if math.abs(mx-p1.x) > math.abs(my-p1.y) then
+      love.graphics.line(px(p1.x),py(p1.y), pmx,     py(p1.y))
+    else
+      love.graphics.line(px(p1.x),py(p1.y), px(p1.x),pmy)
+    end
+  elseif shape.mode == 'polygon' then
+    -- don't close the loop on a pending polygon
+    local prev = nil
+    for _,point in ipairs(shape.vertices) do
+      local curr = drawing.points[point]
+      if prev then
+        love.graphics.line(px(prev.x),py(prev.y), px(curr.x),py(curr.y))
+      end
+      prev = curr
+    end
+    love.graphics.line(px(prev.x),py(prev.y), pmx,pmy)
+  elseif shape.mode == 'rectangle' then
+    local first = drawing.points[shape.vertices[1]]
+    if #shape.vertices == 1 then
+      love.graphics.line(px(first.x),py(first.y), pmx,pmy)
+      return
+    end
+    local second = drawing.points[shape.vertices[2]]
+    local thirdx,thirdy, fourthx,fourthy = Drawing.complete_rectangle(first.x,first.y, second.x,second.y, mx,my)
+    love.graphics.line(px(first.x),py(first.y), px(second.x),py(second.y))
+    love.graphics.line(px(second.x),py(second.y), px(thirdx),py(thirdy))
+    love.graphics.line(px(thirdx),py(thirdy), px(fourthx),py(fourthy))
+    love.graphics.line(px(fourthx),py(fourthy), px(first.x),py(first.y))
+  elseif shape.mode == 'square' then
+    local first = drawing.points[shape.vertices[1]]
+    if #shape.vertices == 1 then
+      love.graphics.line(px(first.x),py(first.y), pmx,pmy)
+      return
+    end
+    local second = drawing.points[shape.vertices[2]]
+    local thirdx,thirdy, fourthx,fourthy = Drawing.complete_square(first.x,first.y, second.x,second.y, mx,my)
+    love.graphics.line(px(first.x),py(first.y), px(second.x),py(second.y))
+    love.graphics.line(px(second.x),py(second.y), px(thirdx),py(thirdy))
+    love.graphics.line(px(thirdx),py(thirdy), px(fourthx),py(fourthy))
+    love.graphics.line(px(fourthx),py(fourthy), px(first.x),py(first.y))
+  elseif shape.mode == 'circle' then
+    local center = drawing.points[shape.center]
+    if mx < 0 or mx >= 256 or my < 0 or my >= drawing.h then
+      return
+    end
+    local cx,cy = px(center.x), py(center.y)
+    love.graphics.circle('line', cx,cy, geom.dist(cx,cy, App.mouse_x(),App.mouse_y()))
+  elseif shape.mode == 'arc' then
+    local center = drawing.points[shape.center]
+    if mx < 0 or mx >= 256 or my < 0 or my >= drawing.h then
+      return
+    end
+    shape.end_angle = geom.angle_with_hint(center.x,center.y, mx,my, shape.end_angle)
+    local cx,cy = px(center.x), py(center.y)
+    love.graphics.arc('line', 'open', cx,cy, Drawing.pixels(shape.radius, width), shape.start_angle, shape.end_angle, 360)
+  elseif shape.mode == 'move' then
+    -- nothing pending; changes are immediately committed
+  elseif shape.mode == 'name' then
+    -- nothing pending; changes are immediately committed
+  else
+    print(shape.mode)
+    assert(false)
+  end
+end
+
+function Drawing.in_drawing(drawing, line_cache, x,y, left,right)
+  if line_cache.starty == nil then return false end  -- outside current page
+  local width = right-left
+  return y >= line_cache.starty and y < line_cache.starty + Drawing.pixels(drawing.h, width) and x >= left and x < right
+end
+
+function Drawing.mouse_pressed(State, drawing_index, x,y, mouse_button)
+  local drawing = State.lines[drawing_index]
+  local line_cache = State.line_cache[drawing_index]
+  local cx = Drawing.coord(x-State.left, State.width)
+  local cy = Drawing.coord(y-line_cache.starty, State.width)
+  if State.current_drawing_mode == 'freehand' then
+    drawing.pending = {mode=State.current_drawing_mode, points={{x=cx, y=cy}}}
+  elseif State.current_drawing_mode == 'line' or State.current_drawing_mode == 'manhattan' then
+    local j = Drawing.find_or_insert_point(drawing.points, cx, cy, State.width)
+    drawing.pending = {mode=State.current_drawing_mode, p1=j}
+  elseif State.current_drawing_mode == 'polygon' or State.current_drawing_mode == 'rectangle' or State.current_drawing_mode == 'square' then
+    local j = Drawing.find_or_insert_point(drawing.points, cx, cy, State.width)
+    drawing.pending = {mode=State.current_drawing_mode, vertices={j}}
+  elseif State.current_drawing_mode == 'circle' then
+    local j = Drawing.find_or_insert_point(drawing.points, cx, cy, State.width)
+    drawing.pending = {mode=State.current_drawing_mode, center=j}
+  elseif State.current_drawing_mode == 'move' then
+    -- all the action is in mouse_released
+  elseif State.current_drawing_mode == 'name' then
+    -- nothing
+  else
+    print(State.current_drawing_mode)
+    assert(false)
+  end
+end
+
+-- a couple of operations on drawings need to constantly check the state of the mouse
+function Drawing.update(State)
+  if State.lines.current_drawing == nil then return end
+  local drawing = State.lines.current_drawing
+  local line_cache = State.line_cache[State.lines.current_drawing_index]
+  assert(drawing.mode == 'drawing')
+  local pmx, pmy = App.mouse_x(), App.mouse_y()
+  local mx = Drawing.coord(pmx-State.left, State.width)
+  local my = Drawing.coord(pmy-line_cache.starty, State.width)
+  if App.mouse_down(1) then
+    if Drawing.in_drawing(drawing, line_cache, pmx,pmy, State.left,State.right) then
+      if drawing.pending.mode == 'freehand' then
+        table.insert(drawing.pending.points, {x=mx, y=my})
+      elseif drawing.pending.mode == 'move' then
+        drawing.pending.target_point.x = mx
+        drawing.pending.target_point.y = my
+        Drawing.relax_constraints(drawing, drawing.pending.target_point_index)
+      end
+    end
+  elseif State.current_drawing_mode == 'move' then
+    if Drawing.in_drawing(drawing, line_cache, pmx, pmy, State.left,State.right) then
+      drawing.pending.target_point.x = mx
+      drawing.pending.target_point.y = my
+      Drawing.relax_constraints(drawing, drawing.pending.target_point_index)
+    end
+  else
+    -- do nothing
+  end
+end
+
+function Drawing.relax_constraints(drawing, p)
+  for _,shape in ipairs(drawing.shapes) do
+    if shape.mode == 'manhattan' then
+      if shape.p1 == p then
+        shape.mode = 'line'
+      elseif shape.p2 == p then
+        shape.mode = 'line'
+      end
+    elseif shape.mode == 'rectangle' or shape.mode == 'square' then
+      for _,v in ipairs(shape.vertices) do
+        if v == p then
+          shape.mode = 'polygon'
+        end
+      end
+    end
+  end
+end
+
+function Drawing.mouse_released(State, x,y, mouse_button)
+  if State.current_drawing_mode == 'move' then
+    State.current_drawing_mode = State.previous_drawing_mode
+    State.previous_drawing_mode = nil
+    if State.lines.current_drawing then
+      State.lines.current_drawing.pending = {}
+      State.lines.current_drawing = nil
+    end
+  elseif State.lines.current_drawing then
+    local drawing = State.lines.current_drawing
+    local line_cache = State.line_cache[State.lines.current_drawing_index]
+    if drawing.pending then
+      if drawing.pending.mode == nil then
+        -- nothing pending
+      elseif drawing.pending.mode == 'freehand' then
+        -- the last point added during update is good enough
+        Drawing.smoothen(drawing.pending)
+        table.insert(drawing.shapes, drawing.pending)
+      elseif drawing.pending.mode == 'line' then
+        local mx,my = Drawing.coord(x-State.left, State.width), Drawing.coord(y-line_cache.starty, State.width)
+        if mx >= 0 and mx < 256 and my >= 0 and my < drawing.h then
+          drawing.pending.p2 = Drawing.find_or_insert_point(drawing.points, mx,my, State.width)
+          table.insert(drawing.shapes, drawing.pending)
+        end
+      elseif drawing.pending.mode == 'manhattan' then
+        local p1 = drawing.points[drawing.pending.p1]
+        local mx,my = Drawing.coord(x-State.left, State.width), Drawing.coord(y-line_cache.starty, State.width)
+        if mx >= 0 and mx < 256 and my >= 0 and my < drawing.h then
+          if math.abs(mx-p1.x) > math.abs(my-p1.y) then
+            drawing.pending.p2 = Drawing.find_or_insert_point(drawing.points, mx, p1.y, State.width)
+          else
+            drawing.pending.p2 = Drawing.find_or_insert_point(drawing.points, p1.x, my, State.width)
+          end
+          local p2 = drawing.points[drawing.pending.p2]
+          App.mouse_move(State.left+Drawing.pixels(p2.x, State.width), line_cache.starty+Drawing.pixels(p2.y, State.width))
+          table.insert(drawing.shapes, drawing.pending)
+        end
+      elseif drawing.pending.mode == 'polygon' then
+        local mx,my = Drawing.coord(x-State.left, State.width), Drawing.coord(y-line_cache.starty, State.width)
+        if mx >= 0 and mx < 256 and my >= 0 and my < drawing.h then
+          table.insert(drawing.pending.vertices, Drawing.find_or_insert_point(drawing.points, mx,my, State.width))
+          table.insert(drawing.shapes, drawing.pending)
+        end
+      elseif drawing.pending.mode == 'rectangle' then
+        assert(#drawing.pending.vertices <= 2)
+        if #drawing.pending.vertices == 2 then
+          local mx,my = Drawing.coord(x-State.left, State.width), Drawing.coord(y-line_cache.starty, State.width)
+          if mx >= 0 and mx < 256 and my >= 0 and my < drawing.h then
+            local first = drawing.points[drawing.pending.vertices[1]]
+            local second = drawing.points[drawing.pending.vertices[2]]
+            local thirdx,thirdy, fourthx,fourthy = Drawing.complete_rectangle(first.x,first.y, second.x,second.y, mx,my)
+            table.insert(drawing.pending.vertices, Drawing.find_or_insert_point(drawing.points, thirdx,thirdy, State.width))
+            table.insert(drawing.pending.vertices, Drawing.find_or_insert_point(drawing.points, fourthx,fourthy, State.width))
+            table.insert(drawing.shapes, drawing.pending)
+          end
+        else
+          -- too few points; draw nothing
+        end
+      elseif drawing.pending.mode == 'square' then
+        assert(#drawing.pending.vertices <= 2)
+        if #drawing.pending.vertices == 2 then
+          local mx,my = Drawing.coord(x-State.left, State.width), Drawing.coord(y-line_cache.starty, State.width)
+          if mx >= 0 and mx < 256 and my >= 0 and my < drawing.h then
+            local first = drawing.points[drawing.pending.vertices[1]]
+            local second = drawing.points[drawing.pending.vertices[2]]
+            local thirdx,thirdy, fourthx,fourthy = Drawing.complete_square(first.x,first.y, second.x,second.y, mx,my)
+            table.insert(drawing.pending.vertices, Drawing.find_or_insert_point(drawing.points, thirdx,thirdy, State.width))
+            table.insert(drawing.pending.vertices, Drawing.find_or_insert_point(drawing.points, fourthx,fourthy, State.width))
+            table.insert(drawing.shapes, drawing.pending)
+          end
+        end
+      elseif drawing.pending.mode == 'circle' then
+        local mx,my = Drawing.coord(x-State.left, State.width), Drawing.coord(y-line_cache.starty, State.width)
+        if mx >= 0 and mx < 256 and my >= 0 and my < drawing.h then
+          local center = drawing.points[drawing.pending.center]
+          drawing.pending.radius = round(geom.dist(center.x,center.y, mx,my))
+          table.insert(drawing.shapes, drawing.pending)
+        end
+      elseif drawing.pending.mode == 'arc' then
+        local mx,my = Drawing.coord(x-State.left, State.width), Drawing.coord(y-line_cache.starty, State.width)
+        if mx >= 0 and mx < 256 and my >= 0 and my < drawing.h then
+          local center = drawing.points[drawing.pending.center]
+          drawing.pending.end_angle = geom.angle_with_hint(center.x,center.y, mx,my, drawing.pending.end_angle)
+          table.insert(drawing.shapes, drawing.pending)
+        end
+      elseif drawing.pending.mode == 'name' then
+        -- drop it
+      else
+        print(drawing.pending.mode)
+        assert(false)
+      end
+      State.lines.current_drawing.pending = {}
+      State.lines.current_drawing = nil
+    end
+  end
+end
+
+function Drawing.keychord_pressed(State, chord)
+  if chord == 'C-p' and not App.mouse_down(1) then
+    State.current_drawing_mode = 'freehand'
+  elseif App.mouse_down(1) and chord == 'l' then
+    State.current_drawing_mode = 'line'
+    local _,drawing = Drawing.current_drawing(State)
+    if drawing.pending.mode == 'freehand' then
+      drawing.pending.p1 = Drawing.find_or_insert_point(drawing.points, drawing.pending.points[1].x, drawing.pending.points[1].y, State.width)
+    elseif drawing.pending.mode == 'polygon' or drawing.pending.mode == 'rectangle' or drawing.pending.mode == 'square' then
+      drawing.pending.p1 = drawing.pending.vertices[1]
+    elseif drawing.pending.mode == 'circle' or drawing.pending.mode == 'arc' then
+      drawing.pending.p1 = drawing.pending.center
+    end
+    drawing.pending.mode = 'line'
+  elseif chord == 'C-l' and not App.mouse_down(1) then
+    State.current_drawing_mode = 'line'
+  elseif App.mouse_down(1) and chord == 'm' then
+    State.current_drawing_mode = 'manhattan'
+    local drawing = Drawing.select_drawing_at_mouse(State)
+    if drawing.pending.mode == 'freehand' then
+      drawing.pending.p1 = Drawing.find_or_insert_point(drawing.points, drawing.pending.points[1].x, drawing.pending.points[1].y, State.width)
+    elseif drawing.pending.mode == 'line' then
+      -- do nothing
+    elseif drawing.pending.mode == 'polygon' or drawing.pending.mode == 'rectangle' or drawing.pending.mode == 'square' then
+      drawing.pending.p1 = drawing.pending.vertices[1]
+    elseif drawing.pending.mode == 'circle' or drawing.pending.mode == 'arc' then
+      drawing.pending.p1 = drawing.pending.center
+    end
+    drawing.pending.mode = 'manhattan'
+  elseif chord == 'C-m' and not App.mouse_down(1) then
+    State.current_drawing_mode = 'manhattan'
+  elseif chord == 'C-g' and not App.mouse_down(1) then
+    State.current_drawing_mode = 'polygon'
+  elseif App.mouse_down(1) and chord == 'g' then
+    State.current_drawing_mode = 'polygon'
+    local _,drawing = Drawing.current_drawing(State)
+    if drawing.pending.mode == 'freehand' then
+      drawing.pending.vertices = {Drawing.find_or_insert_point(drawing.points, drawing.pending.points[1].x, drawing.pending.points[1].y, State.width)}
+    elseif drawing.pending.mode == 'line' or drawing.pending.mode == 'manhattan' then
+      if drawing.pending.vertices == nil then
+        drawing.pending.vertices = {drawing.pending.p1}
+      end
+    elseif drawing.pending.mode == 'rectangle' or drawing.pending.mode == 'square' then
+      -- reuse existing vertices
+    elseif drawing.pending.mode == 'circle' or drawing.pending.mode == 'arc' then
+      drawing.pending.vertices = {drawing.pending.center}
+    end
+    drawing.pending.mode = 'polygon'
+  elseif chord == 'C-r' and not App.mouse_down(1) then
+    State.current_drawing_mode = 'rectangle'
+  elseif App.mouse_down(1) and chord == 'r' then
+    State.current_drawing_mode = 'rectangle'
+    local _,drawing = Drawing.current_drawing(State)
+    if drawing.pending.mode == 'freehand' then
+      drawing.pending.vertices = {Drawing.find_or_insert_point(drawing.points, drawing.pending.points[1].x, drawing.pending.points[1].y, State.width)}
+    elseif drawing.pending.mode == 'line' or drawing.pending.mode == 'manhattan' then
+      if drawing.pending.vertices == nil then
+        drawing.pending.vertices = {drawing.pending.p1}
+      end
+    elseif drawing.pending.mode == 'polygon' or drawing.pending.mode == 'square' then
+      -- reuse existing (1-2) vertices
+    elseif drawing.pending.mode == 'circle' or drawing.pending.mode == 'arc' then
+      drawing.pending.vertices = {drawing.pending.center}
+    end
+    drawing.pending.mode = 'rectangle'
+  elseif chord == 'C-s' and not App.mouse_down(1) then
+    State.current_drawing_mode = 'square'
+  elseif App.mouse_down(1) and chord == 's' then
+    State.current_drawing_mode = 'square'
+    local _,drawing = Drawing.current_drawing(State)
+    if drawing.pending.mode == 'freehand' then
+      drawing.pending.vertices = {Drawing.find_or_insert_point(drawing.points, drawing.pending.points[1].x, drawing.pending.points[1].y, State.width)}
+    elseif drawing.pending.mode == 'line' or drawing.pending.mode == 'manhattan' then
+      if drawing.pending.vertices == nil then
+        drawing.pending.vertices = {drawing.pending.p1}
+      end
+    elseif drawing.pending.mode == 'polygon' then
+      while #drawing.pending.vertices > 2 do
+        table.remove(drawing.pending.vertices)
+      end
+    elseif drawing.pending.mode == 'rectangle' then
+      -- reuse existing (1-2) vertices
+    elseif drawing.pending.mode == 'circle' or drawing.pending.mode == 'arc' then
+      drawing.pending.vertices = {drawing.pending.center}
+    end
+    drawing.pending.mode = 'square'
+  elseif App.mouse_down(1) and chord == 'p' and State.current_drawing_mode == 'polygon' then
+    local _,drawing,line_cache = Drawing.current_drawing(State)
+    local mx,my = Drawing.coord(App.mouse_x()-State.left, State.width), Drawing.coord(App.mouse_y()-line_cache.starty, State.width)
+    local j = Drawing.find_or_insert_point(drawing.points, mx,my, State.width)
+    table.insert(drawing.pending.vertices, j)
+  elseif App.mouse_down(1) and chord == 'p' and (State.current_drawing_mode == 'rectangle' or State.current_drawing_mode == 'square') then
+    local _,drawing,line_cache = Drawing.current_drawing(State)
+    local mx,my = Drawing.coord(App.mouse_x()-State.left, State.width), Drawing.coord(App.mouse_y()-line_cache.starty, State.width)
+    local j = Drawing.find_or_insert_point(drawing.points, mx,my, State.width)
+    while #drawing.pending.vertices >= 2 do
+      table.remove(drawing.pending.vertices)
+    end
+    table.insert(drawing.pending.vertices, j)
+  elseif chord == 'C-o' and not App.mouse_down(1) then
+    State.current_drawing_mode = 'circle'
+  elseif App.mouse_down(1) and chord == 'a' and State.current_drawing_mode == 'circle' then
+    local _,drawing,line_cache = Drawing.current_drawing(State)
+    drawing.pending.mode = 'arc'
+    local mx,my = Drawing.coord(App.mouse_x()-State.left, State.width), Drawing.coord(App.mouse_y()-line_cache.starty, State.width)
+    local center = drawing.points[drawing.pending.center]
+    drawing.pending.radius = round(geom.dist(center.x,center.y, mx,my))
+    drawing.pending.start_angle = geom.angle(center.x,center.y, mx,my)
+  elseif App.mouse_down(1) and chord == 'o' then
+    State.current_drawing_mode = 'circle'
+    local _,drawing = Drawing.current_drawing(State)
+    if drawing.pending.mode == 'freehand' then
+      drawing.pending.center = Drawing.find_or_insert_point(drawing.points, drawing.pending.points[1].x, drawing.pending.points[1].y, State.width)
+    elseif drawing.pending.mode == 'line' or drawing.pending.mode == 'manhattan' then
+      drawing.pending.center = drawing.pending.p1
+    elseif drawing.pending.mode == 'polygon' or drawing.pending.mode == 'rectangle' or drawing.pending.mode == 'square' then
+      drawing.pending.center = drawing.pending.vertices[1]
+    end
+    drawing.pending.mode = 'circle'
+  elseif chord == 'C-u' and not App.mouse_down(1) then
+    local drawing_index,drawing,line_cache,i,p = Drawing.select_point_at_mouse(State)
+    if drawing then
+      if State.previous_drawing_mode == nil then
+        State.previous_drawing_mode = State.current_drawing_mode
+      end
+      State.current_drawing_mode = 'move'
+      drawing.pending = {mode=State.current_drawing_mode, target_point=p, target_point_index=i}
+      State.lines.current_drawing_index = drawing_index
+      State.lines.current_drawing = drawing
+    end
+  elseif chord == 'C-n' and not App.mouse_down(1) then
+    local drawing_index,drawing,line_cache,point_index,p = Drawing.select_point_at_mouse(State)
+    if drawing then
+      if State.previous_drawing_mode == nil then
+        -- don't clobber
+        State.previous_drawing_mode = State.current_drawing_mode
+      end
+      State.current_drawing_mode = 'name'
+      p.name = ''
+      drawing.pending = {mode=State.current_drawing_mode, target_point=point_index}
+      State.lines.current_drawing_index = drawing_index
+      State.lines.current_drawing = drawing
+    end
+  elseif chord == 'C-d' and not App.mouse_down(1) then
+    local _,drawing,_,i,p = Drawing.select_point_at_mouse(State)
+    if drawing then
+      for _,shape in ipairs(drawing.shapes) do
+        if Drawing.contains_point(shape, i) then
+          if shape.mode == 'polygon' then
+            local idx = table.find(shape.vertices, i)
+            assert(idx)
+            table.remove(shape.vertices, idx)
+            if #shape.vertices < 3 then
+              shape.mode = 'deleted'
+            end
+          else
+            shape.mode = 'deleted'
+          end
+        end
+      end
+      drawing.points[i].deleted = true
+    end
+    local drawing,_,_,shape = Drawing.select_shape_at_mouse(State)
+    if drawing then
+      shape.mode = 'deleted'
+    end
+  elseif chord == 'C-h' and not App.mouse_down(1) then
+    local drawing = Drawing.select_drawing_at_mouse(State)
+    if drawing then
+      drawing.show_help = true
+    end
+  elseif chord == 'escape' and App.mouse_down(1) then
+    local _,drawing = Drawing.current_drawing(State)
+    drawing.pending = {}
+  end
+end
+
+function Drawing.complete_rectangle(firstx,firsty, secondx,secondy, x,y)
+  if firstx == secondx then
+    return x,secondy, x,firsty
+  end
+  if firsty == secondy then
+    return secondx,y, firstx,y
+  end
+  local first_slope = (secondy-firsty)/(secondx-firstx)
+  -- slope of second edge:
+  --    -1/first_slope
+  -- equation of line containing the second edge:
+  --    y-secondy = -1/first_slope*(x-secondx)
+  -- => 1/first_slope*x + y + (- secondy - secondx/first_slope) = 0
+  -- now we want to find the point on this line that's closest to the mouse pointer.
+  -- https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_an_equation
+  local a = 1/first_slope
+  local c = -secondy - secondx/first_slope
+  local thirdx = round(((x-a*y) - a*c) / (a*a + 1))
+  local thirdy = round((a*(-x + a*y) - c) / (a*a + 1))
+  -- slope of third edge = first_slope
+  -- equation of line containing third edge:
+  --     y - thirdy = first_slope*(x-thirdx)
+  --  => -first_slope*x + y + (-thirdy + thirdx*first_slope) = 0
+  -- now we want to find the point on this line that's closest to the first point
+  local a = -first_slope
+  local c = -thirdy + thirdx*first_slope
+  local fourthx = round(((firstx-a*firsty) - a*c) / (a*a + 1))
+  local fourthy = round((a*(-firstx + a*firsty) - c) / (a*a + 1))
+  return thirdx,thirdy, fourthx,fourthy
+end
+
+function Drawing.complete_square(firstx,firsty, secondx,secondy, x,y)
+  -- use x,y only to decide which side of the first edge to complete the square on
+  local deltax = secondx-firstx
+  local deltay = secondy-firsty
+  local thirdx = secondx+deltay
+  local thirdy = secondy-deltax
+  if not geom.same_side(firstx,firsty, secondx,secondy, thirdx,thirdy, x,y) then
+    deltax = -deltax
+    deltay = -deltay
+    thirdx = secondx+deltay
+    thirdy = secondy-deltax
+  end
+  local fourthx = firstx+deltay
+  local fourthy = firsty-deltax
+  return thirdx,thirdy, fourthx,fourthy
+end
+
+function Drawing.current_drawing(State)
+  local x, y = App.mouse_x(), App.mouse_y()
+  for drawing_index,drawing in ipairs(State.lines) do
+    if drawing.mode == 'drawing' then
+      local line_cache = State.line_cache[drawing_index]
+      if Drawing.in_drawing(drawing, line_cache, x,y, State.left,State.right) then
+        return drawing_index,drawing,line_cache
+      end
+    end
+  end
+  return nil
+end
+
+function Drawing.select_shape_at_mouse(State)
+  for drawing_index,drawing in ipairs(State.lines) do
+    if drawing.mode == 'drawing' then
+      local x, y = App.mouse_x(), App.mouse_y()
+      local line_cache = State.line_cache[drawing_index]
+      if Drawing.in_drawing(drawing, line_cache, x,y, State.left,State.right) then
+        local mx,my = Drawing.coord(x-State.left, State.width), Drawing.coord(y-line_cache.starty, State.width)
+        for i,shape in ipairs(drawing.shapes) do
+          assert(shape)
+          if geom.on_shape(mx,my, drawing, shape) then
+            return drawing,line_cache,i,shape
+          end
+        end
+      end
+    end
+  end
+end
+
+function Drawing.select_point_at_mouse(State)
+  for drawing_index,drawing in ipairs(State.lines) do
+    if drawing.mode == 'drawing' then
+      local x, y = App.mouse_x(), App.mouse_y()
+      local line_cache = State.line_cache[drawing_index]
+      if Drawing.in_drawing(drawing, line_cache, x,y, State.left,State.right) then
+        local mx,my = Drawing.coord(x-State.left, State.width), Drawing.coord(y-line_cache.starty, State.width)
+        for i,point in ipairs(drawing.points) do
+          assert(point)
+          if Drawing.near(point, mx,my, State.width) then
+            return drawing_index,drawing,line_cache,i,point
+          end
+        end
+      end
+    end
+  end
+end
+
+function Drawing.select_drawing_at_mouse(State)
+  for drawing_index,drawing in ipairs(State.lines) do
+    if drawing.mode == 'drawing' then
+      local x, y = App.mouse_x(), App.mouse_y()
+      local line_cache = State.line_cache[drawing_index]
+      if Drawing.in_drawing(drawing, line_cache, x,y, State.left,State.right) then
+        return drawing
+      end
+    end
+  end
+end
+
+function Drawing.contains_point(shape, p)
+  if shape.mode == 'freehand' then
+    -- not supported
+  elseif shape.mode == 'line' or shape.mode == 'manhattan' then
+    return shape.p1 == p or shape.p2 == p
+  elseif shape.mode == 'polygon' or shape.mode == 'rectangle' or shape.mode == 'square' then
+    return table.find(shape.vertices, p)
+  elseif shape.mode == 'circle' then
+    return shape.center == p
+  elseif shape.mode == 'arc' then
+    return shape.center == p
+    -- ugh, how to support angles
+  elseif shape.mode == 'deleted' then
+    -- already done
+  else
+    print(shape.mode)
+    assert(false)
+  end
+end
+
+function Drawing.smoothen(shape)
+  assert(shape.mode == 'freehand')
+  for _=1,7 do
+    for i=2,#shape.points-1 do
+      local a = shape.points[i-1]
+      local b = shape.points[i]
+      local c = shape.points[i+1]
+      b.x = round((a.x + b.x + c.x)/3)
+      b.y = round((a.y + b.y + c.y)/3)
+    end
+  end
+end
+
+function round(num)
+  return math.floor(num+.5)
+end
+
+function Drawing.find_or_insert_point(points, x,y, width)
+  -- check if UI would snap the two points together
+  for i,point in ipairs(points) do
+    if Drawing.near(point, x,y, width) then
+      return i
+    end
+  end
+  table.insert(points, {x=x, y=y})
+  return #points
+end
+
+function Drawing.near(point, x,y, width)
+  local px,py = Drawing.pixels(x, width),Drawing.pixels(y, width)
+  local cx,cy = Drawing.pixels(point.x, width), Drawing.pixels(point.y, width)
+  return (cx-px)*(cx-px) + (cy-py)*(cy-py) < Same_point_distance*Same_point_distance
+end
+
+function Drawing.pixels(n, width)  -- parts to pixels
+  return math.floor(n*width/256)
+end
+function Drawing.coord(n, width)  -- pixels to parts
+  return math.floor(n*256/width)
+end
+
+function table.find(h, x)
+  for k,v in pairs(h) do
+    if v == x then
+      return k
+    end
+  end
+end
diff --git a/drawing_tests.lua b/drawing_tests.lua
new file mode 100644
index 0000000..f1e39a6
--- /dev/null
+++ b/drawing_tests.lua
@@ -0,0 +1,785 @@
+-- major tests for drawings
+-- We minimize assumptions about specific pixels, and try to test at the level
+-- of specific shapes. In particular, no tests of freehand drawings.
+
+function test_creating_drawing_saves()
+  io.write('\ntest_creating_drawing_saves')
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{}
+  Text.redraw_all(Editor_state)
+  edit.draw(Editor_state)
+  -- click on button to create drawing
+  edit.run_after_mouse_click(Editor_state, 8,Editor_state.top+8, 1)
+  -- file not immediately saved
+  edit.update(Editor_state, 0.01)
+  check_nil(App.filesystem['foo'], 'F - test_creating_drawing_saves/early')
+  -- wait until save
+  App.wait_fake_time(3.1)
+  edit.update(Editor_state, 0)
+  -- filesystem contains drawing and an empty line of text
+  check_eq(App.filesystem['foo'], '```lines\n```\n\n', 'F - test_creating_drawing_saves')
+end
+
+function test_draw_line()
+  io.write('\ntest_draw_line')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  check_eq(#Editor_state.lines, 2, 'F - test_draw_line/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_draw_line/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_draw_line/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_draw_line/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_draw_line/baseline/#shapes')
+  -- draw a line
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_draw_line/#shapes')
+  check_eq(#drawing.points, 2, 'F - test_draw_line/#points')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_draw_line/shape:1')
+  local p1 = drawing.points[drawing.shapes[1].p1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(p1.x, 5, 'F - test_draw_line/p1:x')
+  check_eq(p1.y, 6, 'F - test_draw_line/p1:y')
+  check_eq(p2.x, 35, 'F - test_draw_line/p2:x')
+  check_eq(p2.y, 36, 'F - test_draw_line/p2:y')
+  -- wait until save
+  App.wait_fake_time(3.1)
+  edit.update(Editor_state, 0)
+  -- The format on disk isn't perfectly stable. Table fields can be reordered.
+  -- So just reload from disk to verify.
+  load_from_disk(Editor_state)
+  Text.redraw_all(Editor_state)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_draw_line/save/#shapes')
+  check_eq(#drawing.points, 2, 'F - test_draw_line/save/#points')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_draw_line/save/shape:1')
+  local p1 = drawing.points[drawing.shapes[1].p1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(p1.x, 5, 'F - test_draw_line/save/p1:x')
+  check_eq(p1.y, 6, 'F - test_draw_line/save/p1:y')
+  check_eq(p2.x, 35, 'F - test_draw_line/save/p2:x')
+  check_eq(p2.y, 36, 'F - test_draw_line/save/p2:y')
+end
+
+function test_draw_horizontal_line()
+  io.write('\ntest_draw_horizontal_line')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'manhattan'
+  edit.draw(Editor_state)
+  check_eq(#Editor_state.lines, 2, 'F - test_draw_horizontal_line/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_draw_horizontal_line/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_draw_horizontal_line/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_draw_horizontal_line/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_draw_horizontal_line/baseline/#shapes')
+  -- draw a line that is more horizontal than vertical
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+26, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_draw_horizontal_line/#shapes')
+  check_eq(#drawing.points, 2, 'F - test_draw_horizontal_line/#points')
+  check_eq(drawing.shapes[1].mode, 'manhattan', 'F - test_draw_horizontal_line/shape_mode')
+  local p1 = drawing.points[drawing.shapes[1].p1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(p1.x, 5, 'F - test_draw_horizontal_line/p1:x')
+  check_eq(p1.y, 6, 'F - test_draw_horizontal_line/p1:y')
+  check_eq(p2.x, 35, 'F - test_draw_horizontal_line/p2:x')
+  check_eq(p2.y, p1.y, 'F - test_draw_horizontal_line/p2:y')
+end
+
+function test_draw_circle()
+  io.write('\ntest_draw_circle')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  check_eq(#Editor_state.lines, 2, 'F - test_draw_circle/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_draw_circle/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_draw_circle/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_draw_circle/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_draw_circle/baseline/#shapes')
+  -- draw a circle
+  App.mouse_move(Editor_state.left+4, Editor_state.top+Drawing_padding_top+4)  -- hover on drawing
+  edit.run_after_keychord(Editor_state, 'C-o')
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35+30, Editor_state.top+Drawing_padding_top+36, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_draw_circle/#shapes')
+  check_eq(#drawing.points, 1, 'F - test_draw_circle/#points')
+  check_eq(drawing.shapes[1].mode, 'circle', 'F - test_draw_horizontal_line/shape_mode')
+  check_eq(drawing.shapes[1].radius, 30, 'F - test_draw_circle/radius')
+  local center = drawing.points[drawing.shapes[1].center]
+  check_eq(center.x, 35, 'F - test_draw_circle/center:x')
+  check_eq(center.y, 36, 'F - test_draw_circle/center:y')
+end
+
+function test_cancel_stroke()
+  io.write('\ntest_cancel_stroke')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  check_eq(#Editor_state.lines, 2, 'F - test_cancel_stroke/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_cancel_stroke/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_cancel_stroke/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_cancel_stroke/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_cancel_stroke/baseline/#shapes')
+  -- start drawing a line
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  -- cancel
+  edit.run_after_keychord(Editor_state, 'escape')
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 0, 'F - test_cancel_stroke/#shapes')
+end
+
+function test_keys_do_not_affect_shape_when_mouse_up()
+  io.write('\ntest_keys_do_not_affect_shape_when_mouse_up')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  -- hover over drawing and press 'o' without holding mouse
+  App.mouse_move(Editor_state.left+4, Editor_state.top+Drawing_padding_top+4)  -- hover on drawing
+  edit.run_after_keychord(Editor_state, 'o')
+  -- no change to drawing mode
+  check_eq(Editor_state.current_drawing_mode, 'line', 'F - test_keys_do_not_affect_shape_when_mouse_up/drawing_mode')
+  -- no change to text either because we didn't run the textinput event
+end
+
+function test_draw_circle_mid_stroke()
+  io.write('\ntest_draw_circle_mid_stroke')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  check_eq(#Editor_state.lines, 2, 'F - test_draw_circle_mid_stroke/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_draw_circle_mid_stroke/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_draw_circle_mid_stroke/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_draw_circle_mid_stroke/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_draw_circle_mid_stroke/baseline/#shapes')
+  -- draw a circle
+  App.mouse_move(Editor_state.left+4, Editor_state.top+Drawing_padding_top+4)  -- hover on drawing
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_keychord(Editor_state, 'o')
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35+30, Editor_state.top+Drawing_padding_top+36, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_draw_circle_mid_stroke/#shapes')
+  check_eq(#drawing.points, 1, 'F - test_draw_circle_mid_stroke/#points')
+  check_eq(drawing.shapes[1].mode, 'circle', 'F - test_draw_horizontal_line/shape_mode')
+  check_eq(drawing.shapes[1].radius, 30, 'F - test_draw_circle_mid_stroke/radius')
+  local center = drawing.points[drawing.shapes[1].center]
+  check_eq(center.x, 35, 'F - test_draw_circle_mid_stroke/center:x')
+  check_eq(center.y, 36, 'F - test_draw_circle_mid_stroke/center:y')
+end
+
+function test_draw_arc()
+  io.write('\ntest_draw_arc')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'circle'
+  edit.draw(Editor_state)
+  check_eq(#Editor_state.lines, 2, 'F - test_draw_arc/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_draw_arc/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_draw_arc/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_draw_arc/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_draw_arc/baseline/#shapes')
+  -- draw an arc
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  App.mouse_move(Editor_state.left+35+30, Editor_state.top+Drawing_padding_top+36)
+  edit.run_after_keychord(Editor_state, 'a')  -- arc mode
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35+50, Editor_state.top+Drawing_padding_top+36+50, 1)  -- 45°
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_draw_arc/#shapes')
+  check_eq(#drawing.points, 1, 'F - test_draw_arc/#points')
+  check_eq(drawing.shapes[1].mode, 'arc', 'F - test_draw_horizontal_line/shape_mode')
+  local arc = drawing.shapes[1]
+  check_eq(arc.radius, 30, 'F - test_draw_arc/radius')
+  local center = drawing.points[arc.center]
+  check_eq(center.x, 35, 'F - test_draw_arc/center:x')
+  check_eq(center.y, 36, 'F - test_draw_arc/center:y')
+  check_eq(arc.start_angle, 0, 'F - test_draw_arc/start:angle')
+  check_eq(arc.end_angle, math.pi/4, 'F - test_draw_arc/end:angle')
+end
+
+function test_draw_polygon()
+  io.write('\ntest_draw_polygon')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  edit.draw(Editor_state)
+  check_eq(Editor_state.current_drawing_mode, 'line', 'F - test_draw_polygon/baseline/drawing_mode')
+  check_eq(#Editor_state.lines, 2, 'F - test_draw_polygon/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_draw_polygon/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_draw_polygon/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_draw_polygon/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_draw_polygon/baseline/#shapes')
+  -- first point
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_keychord(Editor_state, 'g')  -- polygon mode
+  -- second point
+  App.mouse_move(Editor_state.left+65, Editor_state.top+Drawing_padding_top+36)
+  edit.run_after_keychord(Editor_state, 'p')  -- add point
+  -- final point
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+26, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_draw_polygon/#shapes')
+  check_eq(#drawing.points, 3, 'F - test_draw_polygon/vertices')
+  local shape = drawing.shapes[1]
+  check_eq(shape.mode, 'polygon', 'F - test_draw_polygon/shape_mode')
+  check_eq(#shape.vertices, 3, 'F - test_draw_polygon/vertices')
+  local p = drawing.points[shape.vertices[1]]
+  check_eq(p.x, 5, 'F - test_draw_polygon/p1:x')
+  check_eq(p.y, 6, 'F - test_draw_polygon/p1:y')
+  local p = drawing.points[shape.vertices[2]]
+  check_eq(p.x, 65, 'F - test_draw_polygon/p2:x')
+  check_eq(p.y, 36, 'F - test_draw_polygon/p2:y')
+  local p = drawing.points[shape.vertices[3]]
+  check_eq(p.x, 35, 'F - test_draw_polygon/p3:x')
+  check_eq(p.y, 26, 'F - test_draw_polygon/p3:y')
+end
+
+function test_draw_rectangle()
+  io.write('\ntest_draw_rectangle')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  edit.draw(Editor_state)
+  check_eq(Editor_state.current_drawing_mode, 'line', 'F - test_draw_rectangle/baseline/drawing_mode')
+  check_eq(#Editor_state.lines, 2, 'F - test_draw_rectangle/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_draw_rectangle/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_draw_rectangle/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_draw_rectangle/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_draw_rectangle/baseline/#shapes')
+  -- first point
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_keychord(Editor_state, 'r')  -- rectangle mode
+  -- second point/first edge
+  App.mouse_move(Editor_state.left+42, Editor_state.top+Drawing_padding_top+45)
+  edit.run_after_keychord(Editor_state, 'p')
+  -- override second point/first edge
+  App.mouse_move(Editor_state.left+75, Editor_state.top+Drawing_padding_top+76)
+  edit.run_after_keychord(Editor_state, 'p')
+  -- release (decides 'thickness' of rectangle perpendicular to first edge)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+15, Editor_state.top+Drawing_padding_top+26, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_draw_rectangle/#shapes')
+  check_eq(#drawing.points, 5, 'F - test_draw_rectangle/#points')  -- currently includes every point added
+  local shape = drawing.shapes[1]
+  check_eq(shape.mode, 'rectangle', 'F - test_draw_rectangle/shape_mode')
+  check_eq(#shape.vertices, 4, 'F - test_draw_rectangle/vertices')
+  local p = drawing.points[shape.vertices[1]]
+  check_eq(p.x, 35, 'F - test_draw_rectangle/p1:x')
+  check_eq(p.y, 36, 'F - test_draw_rectangle/p1:y')
+  local p = drawing.points[shape.vertices[2]]
+  check_eq(p.x, 75, 'F - test_draw_rectangle/p2:x')
+  check_eq(p.y, 76, 'F - test_draw_rectangle/p2:y')
+  local p = drawing.points[shape.vertices[3]]
+  check_eq(p.x, 70, 'F - test_draw_rectangle/p3:x')
+  check_eq(p.y, 81, 'F - test_draw_rectangle/p3:y')
+  local p = drawing.points[shape.vertices[4]]
+  check_eq(p.x, 30, 'F - test_draw_rectangle/p4:x')
+  check_eq(p.y, 41, 'F - test_draw_rectangle/p4:y')
+end
+
+function test_draw_rectangle_intermediate()
+  io.write('\ntest_draw_rectangle_intermediate')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  edit.draw(Editor_state)
+  check_eq(Editor_state.current_drawing_mode, 'line', 'F - test_draw_rectangle_intermediate/baseline/drawing_mode')
+  check_eq(#Editor_state.lines, 2, 'F - test_draw_rectangle_intermediate/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_draw_rectangle_intermediate/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_draw_rectangle_intermediate/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_draw_rectangle_intermediate/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_draw_rectangle_intermediate/baseline/#shapes')
+  -- first point
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_keychord(Editor_state, 'r')  -- rectangle mode
+  -- second point/first edge
+  App.mouse_move(Editor_state.left+42, Editor_state.top+Drawing_padding_top+45)
+  edit.run_after_keychord(Editor_state, 'p')
+  -- override second point/first edge
+  App.mouse_move(Editor_state.left+75, Editor_state.top+Drawing_padding_top+76)
+  edit.run_after_keychord(Editor_state, 'p')
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.points, 3, 'F - test_draw_rectangle_intermediate/#points')  -- currently includes every point added
+  local pending = drawing.pending
+  check_eq(pending.mode, 'rectangle', 'F - test_draw_rectangle_intermediate/shape_mode')
+  check_eq(#pending.vertices, 2, 'F - test_draw_rectangle_intermediate/vertices')
+  local p = drawing.points[pending.vertices[1]]
+  check_eq(p.x, 35, 'F - test_draw_rectangle_intermediate/p1:x')
+  check_eq(p.y, 36, 'F - test_draw_rectangle_intermediate/p1:y')
+  local p = drawing.points[pending.vertices[2]]
+  check_eq(p.x, 75, 'F - test_draw_rectangle_intermediate/p2:x')
+  check_eq(p.y, 76, 'F - test_draw_rectangle_intermediate/p2:y')
+  -- outline of rectangle is drawn based on where the mouse is, but we can't check that so far
+end
+
+function test_draw_square()
+  io.write('\ntest_draw_square')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  edit.draw(Editor_state)
+  check_eq(Editor_state.current_drawing_mode, 'line', 'F - test_draw_square/baseline/drawing_mode')
+  check_eq(#Editor_state.lines, 2, 'F - test_draw_square/baseline/#lines')
+  check_eq(Editor_state.lines[1].mode, 'drawing', 'F - test_draw_square/baseline/mode')
+  check_eq(Editor_state.line_cache[1].starty, Editor_state.top+Drawing_padding_top, 'F - test_draw_square/baseline/y')
+  check_eq(Editor_state.lines[1].h, 128, 'F - test_draw_square/baseline/y')
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_draw_square/baseline/#shapes')
+  -- first point
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_keychord(Editor_state, 's')  -- square mode
+  -- second point/first edge
+  App.mouse_move(Editor_state.left+42, Editor_state.top+Drawing_padding_top+45)
+  edit.run_after_keychord(Editor_state, 'p')
+  -- override second point/first edge
+  App.mouse_move(Editor_state.left+65, Editor_state.top+Drawing_padding_top+66)
+  edit.run_after_keychord(Editor_state, 'p')
+  -- release (decides which side of first edge to draw square on)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+15, Editor_state.top+Drawing_padding_top+26, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_draw_square/#shapes')
+  check_eq(#drawing.points, 5, 'F - test_draw_square/#points')  -- currently includes every point added
+  check_eq(drawing.shapes[1].mode, 'square', 'F - test_draw_square/shape_mode')
+  check_eq(#drawing.shapes[1].vertices, 4, 'F - test_draw_square/vertices')
+  local p = drawing.points[drawing.shapes[1].vertices[1]]
+  check_eq(p.x, 35, 'F - test_draw_square/p1:x')
+  check_eq(p.y, 36, 'F - test_draw_square/p1:y')
+  local p = drawing.points[drawing.shapes[1].vertices[2]]
+  check_eq(p.x, 65, 'F - test_draw_square/p2:x')
+  check_eq(p.y, 66, 'F - test_draw_square/p2:y')
+  local p = drawing.points[drawing.shapes[1].vertices[3]]
+  check_eq(p.x, 35, 'F - test_draw_square/p3:x')
+  check_eq(p.y, 96, 'F - test_draw_square/p3:y')
+  local p = drawing.points[drawing.shapes[1].vertices[4]]
+  check_eq(p.x, 5, 'F - test_draw_square/p4:x')
+  check_eq(p.y, 66, 'F - test_draw_square/p4:y')
+end
+
+function test_name_point()
+  io.write('\ntest_name_point')
+  -- create a drawing with a line
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  -- draw a line
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_name_point/baseline/#shapes')
+  check_eq(#drawing.points, 2, 'F - test_name_point/baseline/#points')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_name_point/baseline/shape:1')
+  local p1 = drawing.points[drawing.shapes[1].p1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(p1.x, 5, 'F - test_name_point/baseline/p1:x')
+  check_eq(p1.y, 6, 'F - test_name_point/baseline/p1:y')
+  check_eq(p2.x, 35, 'F - test_name_point/baseline/p2:x')
+  check_eq(p2.y, 36, 'F - test_name_point/baseline/p2:y')
+  check_nil(p2.name, 'F - test_name_point/baseline/p2:name')
+  -- enter 'name' mode without moving the mouse
+  edit.run_after_keychord(Editor_state, 'C-n')
+  check_eq(Editor_state.current_drawing_mode, 'name', 'F - test_name_point/mode:1')
+  edit.run_after_textinput(Editor_state, 'A')
+  check_eq(p2.name, 'A', 'F - test_name_point')
+  -- still in 'name' mode
+  check_eq(Editor_state.current_drawing_mode, 'name', 'F - test_name_point/mode:2')
+  -- exit 'name' mode
+  edit.run_after_keychord(Editor_state, 'return')
+  check_eq(Editor_state.current_drawing_mode, 'line', 'F - test_name_point/mode:3')
+  check_eq(p2.name, 'A', 'F - test_name_point')
+  -- wait until save
+  App.wait_fake_time(3.1)
+  edit.update(Editor_state, 0)
+  -- change is saved
+  load_from_disk(Editor_state)
+  Text.redraw_all(Editor_state)
+  local p2 = Editor_state.lines[1].points[drawing.shapes[1].p2]
+  check_eq(p2.name, 'A', 'F - test_name_point/save')
+end
+
+function test_move_point()
+  io.write('\ntest_move_point')
+  -- create a drawing with a line
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_move_point/baseline/#shapes')
+  check_eq(#drawing.points, 2, 'F - test_move_point/baseline/#points')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_move_point/baseline/shape:1')
+  local p1 = drawing.points[drawing.shapes[1].p1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(p1.x, 5, 'F - test_move_point/baseline/p1:x')
+  check_eq(p1.y, 6, 'F - test_move_point/baseline/p1:y')
+  check_eq(p2.x, 35, 'F - test_move_point/baseline/p2:x')
+  check_eq(p2.y, 36, 'F - test_move_point/baseline/p2:y')
+  -- wait until save
+  App.wait_fake_time(3.1)
+  edit.update(Editor_state, 0)
+  -- line is saved to disk
+  load_from_disk(Editor_state)
+  Text.redraw_all(Editor_state)
+  local drawing = Editor_state.lines[1]
+  local p2 = Editor_state.lines[1].points[drawing.shapes[1].p2]
+  check_eq(p2.x, 35, 'F - test_move_point/save/x')
+  check_eq(p2.y, 36, 'F - test_move_point/save/y')
+  edit.draw(Editor_state)
+  -- enter 'move' mode without moving the mouse
+  edit.run_after_keychord(Editor_state, 'C-u')
+  check_eq(Editor_state.current_drawing_mode, 'move', 'F - test_move_point/mode:1')
+  -- point is lifted
+  check_eq(drawing.pending.mode, 'move', 'F - test_move_point/mode:2')
+  check_eq(drawing.pending.target_point, p2, 'F - test_move_point/target')
+  -- move point
+  App.mouse_move(Editor_state.left+26, Editor_state.top+Drawing_padding_top+44)
+  edit.update(Editor_state, 0.05)
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(p2.x, 26, 'F - test_move_point/x')
+  check_eq(p2.y, 44, 'F - test_move_point/y')
+  -- exit 'move' mode
+  edit.run_after_mouse_click(Editor_state, Editor_state.left+26, Editor_state.top+Drawing_padding_top+44, 1)
+  check_eq(Editor_state.current_drawing_mode, 'line', 'F - test_move_point/mode:3')
+  check_eq(drawing.pending, {}, 'F - test_move_point/pending')
+  -- wait until save
+  App.wait_fake_time(3.1)
+  edit.update(Editor_state, 0)
+  -- change is saved
+  load_from_disk(Editor_state)
+  Text.redraw_all(Editor_state)
+  local p2 = Editor_state.lines[1].points[drawing.shapes[1].p2]
+  check_eq(p2.x, 26, 'F - test_move_point/save/x')
+  check_eq(p2.y, 44, 'F - test_move_point/save/y')
+end
+
+function test_move_point_on_manhattan_line()
+  io.write('\ntest_move_point_on_manhattan_line')
+  -- create a drawing with a manhattan line
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'manhattan'
+  edit.draw(Editor_state)
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+46, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_move_point_on_manhattan_line/baseline/#shapes')
+  check_eq(#drawing.points, 2, 'F - test_move_point_on_manhattan_line/baseline/#points')
+  check_eq(drawing.shapes[1].mode, 'manhattan', 'F - test_move_point_on_manhattan_line/baseline/shape:1')
+  edit.draw(Editor_state)
+  -- enter 'move' mode
+  edit.run_after_keychord(Editor_state, 'C-u')
+  check_eq(Editor_state.current_drawing_mode, 'move', 'F - test_move_point_on_manhattan_line/mode:1')
+  -- move point
+  App.mouse_move(Editor_state.left+26, Editor_state.top+Drawing_padding_top+44)
+  edit.update(Editor_state, 0.05)
+  -- line is no longer manhattan
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_move_point_on_manhattan_line/baseline/shape:1')
+end
+
+function test_delete_lines_at_point()
+  io.write('\ntest_delete_lines_at_point')
+  -- create a drawing with two lines connected at a point
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+55, Editor_state.top+Drawing_padding_top+26, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 2, 'F - test_delete_lines_at_point/baseline/#shapes')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_delete_lines_at_point/baseline/shape:1')
+  check_eq(drawing.shapes[2].mode, 'line', 'F - test_delete_lines_at_point/baseline/shape:2')
+  -- hover on the common point and delete
+  App.mouse_move(Editor_state.left+35, Editor_state.top+Drawing_padding_top+36)
+  edit.run_after_keychord(Editor_state, 'C-d')
+  check_eq(drawing.shapes[1].mode, 'deleted', 'F - test_delete_lines_at_point/shape:1')
+  check_eq(drawing.shapes[2].mode, 'deleted', 'F - test_delete_lines_at_point/shape:2')
+  -- wait for some time
+  App.wait_fake_time(3.1)
+  edit.update(Editor_state, 0)
+  -- deleted points disappear after file is reloaded
+  load_from_disk(Editor_state)
+  Text.redraw_all(Editor_state)
+  check_eq(#Editor_state.lines[1].shapes, 0, 'F - test_delete_lines_at_point/save')
+end
+
+function test_delete_line_under_mouse_pointer()
+  io.write('\ntest_delete_line_under_mouse_pointer')
+  -- create a drawing with two lines connected at a point
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+55, Editor_state.top+Drawing_padding_top+26, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 2, 'F - test_delete_line_under_mouse_pointer/baseline/#shapes')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_delete_line_under_mouse_pointer/baseline/shape:1')
+  check_eq(drawing.shapes[2].mode, 'line', 'F - test_delete_line_under_mouse_pointer/baseline/shape:2')
+  -- hover on one of the lines and delete
+  App.mouse_move(Editor_state.left+25, Editor_state.top+Drawing_padding_top+26)
+  edit.run_after_keychord(Editor_state, 'C-d')
+  -- only that line is deleted
+  check_eq(drawing.shapes[1].mode, 'deleted', 'F - test_delete_line_under_mouse_pointer/shape:1')
+  check_eq(drawing.shapes[2].mode, 'line', 'F - test_delete_line_under_mouse_pointer/shape:2')
+end
+
+function test_delete_point_from_polygon()
+  io.write('\ntest_delete_point_from_polygon')
+  -- create a drawing with two lines connected at a point
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  -- first point
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_keychord(Editor_state, 'g')  -- polygon mode
+  -- second point
+  App.mouse_move(Editor_state.left+65, Editor_state.top+Drawing_padding_top+36)
+  edit.run_after_keychord(Editor_state, 'p')  -- add point
+  -- third point
+  App.mouse_move(Editor_state.left+35, Editor_state.top+Drawing_padding_top+26)
+  edit.run_after_keychord(Editor_state, 'p')  -- add point
+  -- fourth point
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+14, Editor_state.top+Drawing_padding_top+16, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_delete_point_from_polygon/baseline/#shapes')
+  check_eq(drawing.shapes[1].mode, 'polygon', 'F - test_delete_point_from_polygon/baseline/mode')
+  check_eq(#drawing.shapes[1].vertices, 4, 'F - test_delete_point_from_polygon/baseline/vertices')
+  -- hover on a point and delete
+  App.mouse_move(Editor_state.left+35, Editor_state.top+Drawing_padding_top+26)
+  edit.run_after_keychord(Editor_state, 'C-d')
+  -- just the one point is deleted
+  check_eq(drawing.shapes[1].mode, 'polygon', 'F - test_delete_point_from_polygon/shape')
+  check_eq(#drawing.shapes[1].vertices, 3, 'F - test_delete_point_from_polygon/vertices')
+end
+
+function test_delete_point_from_polygon()
+  io.write('\ntest_delete_point_from_polygon')
+  -- create a drawing with two lines connected at a point
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  -- first point
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_keychord(Editor_state, 'g')  -- polygon mode
+  -- second point
+  App.mouse_move(Editor_state.left+65, Editor_state.top+Drawing_padding_top+36)
+  edit.run_after_keychord(Editor_state, 'p')  -- add point
+  -- third point
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+14, Editor_state.top+Drawing_padding_top+16, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_delete_point_from_polygon/baseline/#shapes')
+  check_eq(drawing.shapes[1].mode, 'polygon', 'F - test_delete_point_from_polygon/baseline/mode')
+  check_eq(#drawing.shapes[1].vertices, 3, 'F - test_delete_point_from_polygon/baseline/vertices')
+  -- hover on a point and delete
+  App.mouse_move(Editor_state.left+65, Editor_state.top+Drawing_padding_top+36)
+  edit.run_after_keychord(Editor_state, 'C-d')
+  -- there's < 3 points left, so the whole polygon is deleted
+  check_eq(drawing.shapes[1].mode, 'deleted', 'F - test_delete_point_from_polygon')
+end
+
+function test_undo_name_point()
+  io.write('\ntest_undo_name_point')
+  -- create a drawing with a line
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  -- draw a line
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_undo_name_point/baseline/#shapes')
+  check_eq(#drawing.points, 2, 'F - test_undo_name_point/baseline/#points')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_undo_name_point/baseline/shape:1')
+  local p1 = drawing.points[drawing.shapes[1].p1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(p1.x, 5, 'F - test_undo_name_point/baseline/p1:x')
+  check_eq(p1.y, 6, 'F - test_undo_name_point/baseline/p1:y')
+  check_eq(p2.x, 35, 'F - test_undo_name_point/baseline/p2:x')
+  check_eq(p2.y, 36, 'F - test_undo_name_point/baseline/p2:y')
+  check_nil(p2.name, 'F - test_undo_name_point/baseline/p2:name')
+  check_eq(#Editor_state.history, 1, 'F - test_undo_name_point/baseline/history:1')
+--?   print('a', Editor_state.lines.current_drawing)
+  -- enter 'name' mode without moving the mouse
+  edit.run_after_keychord(Editor_state, 'C-n')
+  edit.run_after_textinput(Editor_state, 'A')
+  edit.run_after_keychord(Editor_state, 'return')
+  check_eq(p2.name, 'A', 'F - test_undo_name_point/baseline')
+  check_eq(#Editor_state.history, 3, 'F - test_undo_name_point/baseline/history:2')
+  check_eq(Editor_state.next_history, 4, 'F - test_undo_name_point/baseline/next_history')
+--?   print('b', Editor_state.lines.current_drawing)
+  -- undo
+  edit.run_after_keychord(Editor_state, 'C-z')
+  local drawing = Editor_state.lines[1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(Editor_state.next_history, 3, 'F - test_undo_name_point/next_history')
+  check_eq(p2.name, '', 'F - test_undo_name_point')  -- not quite what it was before, but close enough
+  -- wait until save
+  App.wait_fake_time(3.1)
+  edit.update(Editor_state, 0)
+  -- undo is saved
+  load_from_disk(Editor_state)
+  Text.redraw_all(Editor_state)
+  local p2 = Editor_state.lines[1].points[drawing.shapes[1].p2]
+  check_eq(p2.name, '', 'F - test_undo_name_point/save')
+end
+
+function test_undo_move_point()
+  io.write('\ntest_undo_move_point')
+  -- create a drawing with a line
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 1, 'F - test_undo_move_point/baseline/#shapes')
+  check_eq(#drawing.points, 2, 'F - test_undo_move_point/baseline/#points')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_undo_move_point/baseline/shape:1')
+  local p1 = drawing.points[drawing.shapes[1].p1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(p1.x, 5, 'F - test_undo_move_point/baseline/p1:x')
+  check_eq(p1.y, 6, 'F - test_undo_move_point/baseline/p1:y')
+  check_eq(p2.x, 35, 'F - test_undo_move_point/baseline/p2:x')
+  check_eq(p2.y, 36, 'F - test_undo_move_point/baseline/p2:y')
+  check_nil(p2.name, 'F - test_undo_move_point/baseline/p2:name')
+  -- move p2
+  edit.run_after_keychord(Editor_state, 'C-u')
+  App.mouse_move(Editor_state.left+26, Editor_state.top+Drawing_padding_top+44)
+  edit.update(Editor_state, 0.05)
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(p2.x, 26, 'F - test_undo_move_point/x')
+  check_eq(p2.y, 44, 'F - test_undo_move_point/y')
+  -- exit 'move' mode
+  edit.run_after_mouse_click(Editor_state, Editor_state.left+26, Editor_state.top+Drawing_padding_top+44, 1)
+  check_eq(Editor_state.next_history, 4, 'F - test_undo_move_point/next_history')
+  -- undo
+  edit.run_after_keychord(Editor_state, 'C-z')
+  edit.run_after_keychord(Editor_state, 'C-z')  -- bug: need to undo twice
+  local drawing = Editor_state.lines[1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(Editor_state.next_history, 2, 'F - test_undo_move_point/next_history')
+  check_eq(p2.x, 35, 'F - test_undo_move_point/x')
+  check_eq(p2.y, 36, 'F - test_undo_move_point/y')
+  -- wait until save
+  App.wait_fake_time(3.1)
+  edit.update(Editor_state, 0)
+  -- undo is saved
+  load_from_disk(Editor_state)
+  Text.redraw_all(Editor_state)
+  local p2 = Editor_state.lines[1].points[drawing.shapes[1].p2]
+  check_eq(p2.x, 35, 'F - test_undo_move_point/save/x')
+  check_eq(p2.y, 36, 'F - test_undo_move_point/save/y')
+end
+
+function test_undo_delete_point()
+  io.write('\ntest_undo_delete_point')
+  -- create a drawing with two lines connected at a point
+  App.screen.init{width=Test_margin_left+256, height=300}  -- drawing coordinates 1:1 with pixels
+  Editor_state = edit.initialize_test_state()
+  Editor_state.filename = 'foo'
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  Editor_state.current_drawing_mode = 'line'
+  edit.draw(Editor_state)
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+5, Editor_state.top+Drawing_padding_top+6, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_mouse_press(Editor_state, Editor_state.left+35, Editor_state.top+Drawing_padding_top+36, 1)
+  edit.run_after_mouse_release(Editor_state, Editor_state.left+55, Editor_state.top+Drawing_padding_top+26, 1)
+  local drawing = Editor_state.lines[1]
+  check_eq(#drawing.shapes, 2, 'F - test_undo_delete_point/baseline/#shapes')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_undo_delete_point/baseline/shape:1')
+  check_eq(drawing.shapes[2].mode, 'line', 'F - test_undo_delete_point/baseline/shape:2')
+  -- hover on the common point and delete
+  App.mouse_move(Editor_state.left+35, Editor_state.top+Drawing_padding_top+36)
+  edit.run_after_keychord(Editor_state, 'C-d')
+  check_eq(drawing.shapes[1].mode, 'deleted', 'F - test_undo_delete_point/shape:1')
+  check_eq(drawing.shapes[2].mode, 'deleted', 'F - test_undo_delete_point/shape:2')
+  -- undo
+  edit.run_after_keychord(Editor_state, 'C-z')
+  local drawing = Editor_state.lines[1]
+  local p2 = drawing.points[drawing.shapes[1].p2]
+  check_eq(Editor_state.next_history, 3, 'F - test_undo_move_point/next_history')
+  check_eq(drawing.shapes[1].mode, 'line', 'F - test_undo_delete_point/shape:1')
+  check_eq(drawing.shapes[2].mode, 'line', 'F - test_undo_delete_point/shape:2')
+  -- wait until save
+  App.wait_fake_time(3.1)
+  edit.update(Editor_state, 0)
+  -- undo is saved
+  load_from_disk(Editor_state)
+  Text.redraw_all(Editor_state)
+  check_eq(#Editor_state.lines[1].shapes, 2, 'F - test_undo_delete_point/save')
+end
diff --git a/edit.lua b/edit.lua
index 768313f..bd1efa0 100644
--- a/edit.lua
+++ b/edit.lua
@@ -90,7 +90,7 @@ function edit.draw(State)
     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
+    State.screen_bottom1 = {line=line_index, pos=nil}
 --?     print('text.draw', y, line_index)
     local startpos = 1
     if line_index == State.screen_top1.line then
@@ -100,7 +100,6 @@ function edit.draw(State)
     y = y + State.line_height
 --?     print('=> y', y)
   end
---?   print('screen bottom: '..tostring(State.screen_bottom1.pos)..' in '..tostring(State.lines[State.screen_bottom1.line].data))
   if State.search_term then
     Text.draw_search_bar(State)
   end
@@ -232,7 +231,10 @@ function edit.keychord_pressed(State, chord, key)
     return
   elseif chord == 'C-f' then
     State.search_term = ''
-    State.search_backup = {cursor={line=State.cursor1.line, pos=State.cursor1.pos}, screen_top={line=State.screen_top1.line, pos=State.screen_top1.pos}}
+    State.search_backup = {
+      cursor={line=State.cursor1.line, pos=State.cursor1.pos},
+      screen_top={line=State.screen_top1.line, pos=State.screen_top1.pos},
+    }
     assert(State.search_text == nil)
   -- zoom
   elseif chord == 'C-=' then
diff --git a/file.lua b/file.lua
index e95c14d..98311da 100644
--- a/file.lua
+++ b/file.lua
@@ -37,7 +37,8 @@ function save_to_disk(State)
     error('failed to write to "'..State.filename..'"')
   end
   for _,line in ipairs(State.lines) do
-    outfile:write(line.data, '\n')
+    outfile:write(line.data)
+    outfile:write('\n')
   end
   outfile:close()
 end
diff --git a/geom.lua b/geom.lua
new file mode 100644
index 0000000..891e98d
--- /dev/null
+++ b/geom.lua
@@ -0,0 +1,168 @@
+geom = {}
+
+function geom.on_shape(x,y, drawing, shape)
+  if shape.mode == 'freehand' then
+    return geom.on_freehand(x,y, drawing, shape)
+  elseif shape.mode == 'line' then
+    return geom.on_line(x,y, drawing, shape)
+  elseif shape.mode == 'manhattan' then
+    local p1 = drawing.points[shape.p1]
+    local p2 = drawing.points[shape.p2]
+    if p1.x == p2.x then
+      if x ~= p1.x then return false end
+      local y1,y2 = p1.y, p2.y
+      if y1 > y2 then
+        y1,y2 = y2,y1
+      end
+      return y >= y1-2 and y <= y2+2
+    elseif p1.y == p2.y then
+      if y ~= p1.y then return false end
+      local x1,x2 = p1.x, p2.x
+      if x1 > x2 then
+        x1,x2 = x2,x1
+      end
+      return x >= x1-2 and x <= x2+2
+    end
+  elseif shape.mode == 'polygon' or shape.mode == 'rectangle' or shape.mode == 'square' then
+    return geom.on_polygon(x,y, drawing, shape)
+  elseif shape.mode == 'circle' then
+    local center = drawing.points[shape.center]
+    local dist = geom.dist(center.x,center.y, x,y)
+    return dist > shape.radius*0.95 and dist < shape.radius*1.05
+  elseif shape.mode == 'arc' then
+    local center = drawing.points[shape.center]
+    local dist = geom.dist(center.x,center.y, x,y)
+    if dist < shape.radius*0.95 or dist > shape.radius*1.05 then
+      return false
+    end
+    return geom.angle_between(center.x,center.y, x,y, shape.start_angle,shape.end_angle)
+  elseif shape.mode == 'deleted' then
+  else
+    print(shape.mode)
+    assert(false)
+  end
+end
+
+function geom.on_freehand(x,y, drawing, shape)
+  local prev
+  for _,p in ipairs(shape.points) do
+    if prev then
+      if geom.on_line(x,y, drawing, {p1=prev, p2=p}) then
+        return true
+      end
+    end
+    prev = p
+  end
+  return false
+end
+
+function geom.on_line(x,y, drawing, shape)
+  local p1,p2
+  if type(shape.p1) == 'number' then
+    p1 = drawing.points[shape.p1]
+    p2 = drawing.points[shape.p2]
+  else
+    p1 = shape.p1
+    p2 = shape.p2
+  end
+  if p1.x == p2.x then
+    if math.abs(p1.x-x) > 2 then
+      return false
+    end
+    local y1,y2 = p1.y,p2.y
+    if y1 > y2 then
+      y1,y2 = y2,y1
+    end
+    return y >= y1-2 and y <= y2+2
+  end
+  -- has the right slope and intercept
+  local m = (p2.y - p1.y) / (p2.x - p1.x)
+  local yp = p1.y + m*(x-p1.x)
+  if yp < y-2 or yp > y+2 then
+    return false
+  end
+  -- between endpoints
+  local k = (x-p1.x) / (p2.x-p1.x)
+  return k > -0.005 and k < 1.005
+end
+
+function geom.on_polygon(x,y, drawing, shape)
+  local prev
+  for _,p in ipairs(shape.vertices) do
+    if prev then
+      if geom.on_line(x,y, drawing, {p1=prev, p2=p}) then
+        return true
+      end
+    end
+    prev = p
+  end
+  return geom.on_line(x,y, drawing, {p1=shape.vertices[1], p2=shape.vertices[#shape.vertices]})
+end
+
+-- are (x3,y3) and (x4,y4) on the same side of the line between (x1,y1) and (x2,y2)
+function geom.same_side(x1,y1, x2,y2, x3,y3, x4,y4)
+  if x1 == x2 then
+    return math.sign(x3-x1) == math.sign(x4-x1)
+  end
+  if y1 == y2 then
+    return math.sign(y3-y1) == math.sign(y4-y1)
+  end
+  local m = (y2-y1)/(x2-x1)
+  return math.sign(m*(x3-x1) + y1-y3) == math.sign(m*(x4-x1) + y1-y4)
+end
+
+function math.sign(x)
+  if x > 0 then
+    return 1
+  elseif x == 0 then
+    return 0
+  elseif x < 0 then
+    return -1
+  end
+end
+
+function geom.angle_with_hint(x1, y1, x2, y2, hint)
+  local result = geom.angle(x1,y1, x2,y2)
+  if hint then
+    -- Smooth the discontinuity where angle goes from positive to negative.
+    -- The hint is a memory of which way we drew it last time.
+    while result > hint+math.pi/10 do
+      result = result-math.pi*2
+    end
+    while result < hint-math.pi/10 do
+      result = result+math.pi*2
+    end
+  end
+  return result
+end
+
+-- result is from -Ï€/2 to 3Ï€/2, approximately adding math.atan2 from Lua 5.3
+-- (LÖVE is Lua 5.1)
+function geom.angle(x1,y1, x2,y2)
+  local result = math.atan((y2-y1)/(x2-x1))
+  if x2 < x1 then
+    result = result+math.pi
+  end
+  return result
+end
+
+-- is the line between x,y and cx,cy at an angle between s and e?
+function geom.angle_between(ox,oy, x,y, s,e)
+  local angle = geom.angle(ox,oy, x,y)
+  if s > e then
+    s,e = e,s
+  end
+  -- I'm not sure this is right or ideal..
+  angle = angle-math.pi*2
+  if s <= angle and angle <= e then
+    return true
+  end
+  angle = angle+math.pi*2
+  if s <= angle and angle <= e then
+    return true
+  end
+  angle = angle+math.pi*2
+  return s <= angle and angle <= e
+end
+
+function geom.dist(x1,y1, x2,y2) return ((x2-x1)^2+(y2-y1)^2)^0.5 end
diff --git a/help.lua b/help.lua
new file mode 100644
index 0000000..145692f
--- /dev/null
+++ b/help.lua
@@ -0,0 +1,151 @@
+function draw_help_without_mouse_pressed(State, drawing_index)
+  local drawing = State.lines[drawing_index]
+  local line_cache = State.line_cache[drawing_index]
+  App.color(Help_color)
+  local y = line_cache.starty+10
+  love.graphics.print("Things you can do:", State.left+30,y)
+  y = y + State.line_height
+  love.graphics.print("* Press the mouse button to start drawing a "..current_shape(State), State.left+30,y)
+  y = y + State.line_height
+  love.graphics.print("* Hover on a point and press 'ctrl+u' to pick it up and start moving it,", State.left+30,y)
+  y = y + State.line_height
+  love.graphics.print("then press the mouse button to drop it", State.left+30+bullet_indent(),y)
+  y = y + State.line_height
+  love.graphics.print("* Hover on a point and press 'ctrl+n', type a name, then press 'enter'", State.left+30,y)
+  y = y + State.line_height
+  love.graphics.print("* Hover on a point or shape and press 'ctrl+d' to delete it", State.left+30,y)
+  y = y + State.line_height
+  if State.current_drawing_mode ~= 'freehand' then
+    love.graphics.print("* Press 'ctrl+p' to switch to drawing freehand strokes", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'line' then
+    love.graphics.print("* Press 'ctrl+l' to switch to drawing lines", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'manhattan' then
+    love.graphics.print("* Press 'ctrl+m' to switch to drawing horizontal/vertical lines", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'circle' then
+    love.graphics.print("* Press 'ctrl+o' to switch to drawing circles/arcs", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'polygon' then
+    love.graphics.print("* Press 'ctrl+g' to switch to drawing polygons", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'rectangle' then
+    love.graphics.print("* Press 'ctrl+r' to switch to drawing rectangles", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'square' then
+    love.graphics.print("* Press 'ctrl+s' to switch to drawing squares", State.left+30,y)
+    y = y + State.line_height
+  end
+  love.graphics.print("* Press 'ctrl+=' or 'ctrl+-' to zoom in or out, ctrl+0 to reset zoom", State.left+30,y)
+  y = y + State.line_height
+  love.graphics.print("Press 'esc' now to hide this message", State.left+30,y)
+  y = y + State.line_height
+  App.color(Help_background_color)
+  love.graphics.rectangle('fill', State.left,line_cache.starty, State.width, math.max(Drawing.pixels(drawing.h, State.width),y-line_cache.starty))
+end
+
+function draw_help_with_mouse_pressed(State, drawing_index)
+  local drawing = State.lines[drawing_index]
+  local line_cache = State.line_cache[drawing_index]
+  App.color(Help_color)
+  local y = line_cache.starty+10
+  love.graphics.print("You're currently drawing a "..current_shape(State, drawing.pending), State.left+30,y)
+  y = y + State.line_height
+  love.graphics.print('Things you can do now:', State.left+30,y)
+  y = y + State.line_height
+  if State.current_drawing_mode == 'freehand' then
+    love.graphics.print('* Release the mouse button to finish drawing the stroke', State.left+30,y)
+    y = y + State.line_height
+  elseif State.current_drawing_mode == 'line' or State.current_drawing_mode == 'manhattan' then
+    love.graphics.print('* Release the mouse button to finish drawing the line', State.left+30,y)
+    y = y + State.line_height
+  elseif State.current_drawing_mode == 'circle' then
+    if drawing.pending.mode == 'circle' then
+      love.graphics.print('* Release the mouse button to finish drawing the circle', State.left+30,y)
+      y = y + State.line_height
+      love.graphics.print("* Press 'a' to draw just an arc of a circle", State.left+30,y)
+    else
+      love.graphics.print('* Release the mouse button to finish drawing the arc', State.left+30,y)
+    end
+    y = y + State.line_height
+  elseif State.current_drawing_mode == 'polygon' then
+    love.graphics.print('* Release the mouse button to finish drawing the polygon', State.left+30,y)
+    y = y + State.line_height
+    love.graphics.print("* Press 'p' to add a vertex to the polygon", State.left+30,y)
+    y = y + State.line_height
+  elseif State.current_drawing_mode == 'rectangle' then
+    if #drawing.pending.vertices < 2 then
+      love.graphics.print("* Press 'p' to add a vertex to the rectangle", State.left+30,y)
+      y = y + State.line_height
+    else
+      love.graphics.print('* Release the mouse button to finish drawing the rectangle', State.left+30,y)
+      y = y + State.line_height
+      love.graphics.print("* Press 'p' to replace the second vertex of the rectangle", State.left+30,y)
+      y = y + State.line_height
+    end
+  elseif State.current_drawing_mode == 'square' then
+    if #drawing.pending.vertices < 2 then
+      love.graphics.print("* Press 'p' to add a vertex to the square", State.left+30,y)
+      y = y + State.line_height
+    else
+      love.graphics.print('* Release the mouse button to finish drawing the square', State.left+30,y)
+      y = y + State.line_height
+      love.graphics.print("* Press 'p' to replace the second vertex of the square", State.left+30,y)
+      y = y + State.line_height
+    end
+  end
+  love.graphics.print("* Press 'esc' then release the mouse button to cancel the current shape", State.left+30,y)
+  y = y + State.line_height
+  y = y + State.line_height
+  if State.current_drawing_mode ~= 'line' then
+    love.graphics.print("* Press 'l' to switch to drawing lines", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'manhattan' then
+    love.graphics.print("* Press 'm' to switch to drawing horizontal/vertical lines", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'circle' then
+    love.graphics.print("* Press 'o' to switch to drawing circles/arcs", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'polygon' then
+    love.graphics.print("* Press 'g' to switch to drawing polygons", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'rectangle' then
+    love.graphics.print("* Press 'r' to switch to drawing rectangles", State.left+30,y)
+    y = y + State.line_height
+  end
+  if State.current_drawing_mode ~= 'square' then
+    love.graphics.print("* Press 's' to switch to drawing squares", State.left+30,y)
+    y = y + State.line_height
+  end
+  App.color(Help_background_color)
+  love.graphics.rectangle('fill', State.left,line_cache.starty, State.width, math.max(Drawing.pixels(drawing.h, State.width),y-line_cache.starty))
+end
+
+function current_shape(State, shape)
+  if State.current_drawing_mode == 'freehand' then
+    return 'freehand stroke'
+  elseif State.current_drawing_mode == 'line' then
+    return 'straight line'
+  elseif State.current_drawing_mode == 'manhattan' then
+    return 'horizontal/vertical line'
+  elseif State.current_drawing_mode == 'circle' and shape and shape.start_angle then
+    return 'arc'
+  else
+    return State.current_drawing_mode
+  end
+end
+
+function bullet_indent()
+  return App.width(to_text('* '))
+end
diff --git a/icons.lua b/icons.lua
new file mode 100644
index 0000000..175fb13
--- /dev/null
+++ b/icons.lua
@@ -0,0 +1,59 @@
+icon = {}
+
+function icon.insert_drawing(button_params)
+  local x,y = button_params.x, button_params.y
+  App.color(Icon_color)
+  love.graphics.rectangle('line', x,y, 12,12)
+  love.graphics.line(4,y+6, 16,y+6)
+  love.graphics.line(10,y, 10,y+12)
+end
+
+function icon.freehand(x, y)
+  love.graphics.line(x+4,y+7,x+5,y+5)
+  love.graphics.line(x+5,y+5,x+7,y+4)
+  love.graphics.line(x+7,y+4,x+9,y+3)
+  love.graphics.line(x+9,y+3,x+10,y+5)
+  love.graphics.line(x+10,y+5,x+12,y+6)
+  love.graphics.line(x+12,y+6,x+13,y+8)
+  love.graphics.line(x+13,y+8,x+13,y+10)
+  love.graphics.line(x+13,y+10,x+14,y+12)
+  love.graphics.line(x+14,y+12,x+15,y+14)
+  love.graphics.line(x+15,y+14,x+15,y+16)
+end
+
+function icon.line(x, y)
+  love.graphics.line(x+4,y+2, x+16,y+18)
+end
+
+function icon.manhattan(x, y)
+  love.graphics.line(x+4,y+20, x+4,y+2)
+  love.graphics.line(x+4,y+2, x+10,y+2)
+  love.graphics.line(x+10,y+2, x+10,y+10)
+  love.graphics.line(x+10,y+10, x+18,y+10)
+end
+
+function icon.polygon(x, y)
+  love.graphics.line(x+8,y+2, x+14,y+2)
+  love.graphics.line(x+14,y+2, x+18,y+10)
+  love.graphics.line(x+18,y+10, x+10,y+18)
+  love.graphics.line(x+10,y+18, x+4,y+12)
+  love.graphics.line(x+4,y+12, x+8,y+2)
+end
+
+function icon.rectangle(x, y)
+  love.graphics.line(x+4,y+8, x+4,y+16)
+  love.graphics.line(x+4,y+16, x+16,y+16)
+  love.graphics.line(x+16,y+16, x+16,y+8)
+  love.graphics.line(x+16,y+8, x+4,y+8)
+end
+
+function icon.square(x, y)
+  love.graphics.line(x+6,y+6, x+6,y+16)
+  love.graphics.line(x+6,y+16, x+16,y+16)
+  love.graphics.line(x+16,y+16, x+16,y+6)
+  love.graphics.line(x+16,y+6, x+6,y+6)
+end
+
+function icon.circle(x, y)
+  love.graphics.circle('line', x+10,y+10, 8)
+end
diff --git a/main.lua b/main.lua
index 544b5db..75c140a 100644
--- a/main.lua
+++ b/main.lua
@@ -45,7 +45,7 @@ function App.load()
         load_file_from_source_or_save_directory('undo.lua')
       load_file_from_source_or_save_directory('text_tests.lua')
     load_file_from_source_or_save_directory('run_tests.lua')
-  else
+  elseif Current_app == 'source' then
     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')
@@ -57,7 +57,14 @@ function App.load()
         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('icons.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')
     load_file_from_source_or_save_directory('source_tests.lua')
+  else
+    assert(false, 'unknown app "'..Current_app..'"')
   end
 end
 
diff --git a/source.lua b/source.lua
index 8f09549..8653a91 100644
--- a/source.lua
+++ b/source.lua
@@ -15,11 +15,15 @@ function source.initialize_globals()
       'run_tests',
       'log',
       'edit',
+      'drawing',
+      'help',
       'text',
       'search',
       'select',
       'undo',
       'text_tests',
+      'geom',
+      'drawing_tests',
       'file',
       'source',
       'source_tests',
@@ -273,7 +277,7 @@ function source.mouse_pressed(x,y, mouse_button)
 --?   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
+  if x < Editor_state.right + Margin_right then
 --?     print('click on edit side')
     if Focus ~= 'edit' then
       Focus = 'edit'
diff --git a/source_edit.lua b/source_edit.lua
index d454467..65f00a2 100644
--- a/source_edit.lua
+++ b/source_edit.lua
@@ -1,8 +1,14 @@
 -- some constants people might like to tweak
 Text_color = {r=0, g=0, b=0}
 Cursor_color = {r=1, g=0, b=0}
+Stroke_color = {r=0, g=0, b=0}
+Current_stroke_color = {r=0.7, g=0.7, b=0.7}  -- in process of being drawn
+Current_name_background_color = {r=1, g=0, b=0, a=0.1}  -- name currently being edited
 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
+Icon_color = {r=0.7, g=0.7, b=0.7}  -- color of current mode icon in drawings
+Help_color = {r=0, g=0.5, b=0}
+Help_background_color = {r=0, g=0.5, b=0, a=0.1}
 Fold_color = {r=0, g=0.6, b=0}
 Fold_background_color = {r=0, g=0.7, b=0}
 
@@ -10,14 +16,40 @@ Margin_top = 15
 Margin_left = 25
 Margin_right = 25
 
+Drawing_padding_top = 10
+Drawing_padding_bottom = 10
+Drawing_padding_height = Drawing_padding_top + Drawing_padding_bottom
+
+Same_point_distance = 4  -- pixel distance at which two points are considered the same
+
 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
+    -- a line is either bifold text or a drawing
+    -- a line of bifold text consists of an A side and an optional B side
+    --    mode = 'text',
+    --    string data,
+    --    string dataB,
+    --    expanded: whether to show B side
+    -- a drawing is a table with:
+    --    mode = 'drawing'
+    --    a (y) coord in pixels (updated while painting screen),
+    --    a (h)eight,
+    --    an array of points, and
+    --    an array of shapes
+    -- a shape is a table containing:
+    --    a mode
+    --    an array points for mode 'freehand' (raw x,y coords; freehand drawings don't pollute the points array of a drawing)
+    --    an array vertices for mode 'polygon', 'rectangle', 'square'
+    --    p1, p2 for mode 'line'
+    --    center, radius for mode 'circle'
+    --    center, radius, start_angle, end_angle for mode 'arc'
+    -- Unless otherwise specified, coord fields are normalized; a drawing is always 256 units wide
+    -- The field names are carefully chosen so that switching modes in midstream
+    -- remembers previously entered points where that makes sense.
+    lines = {{mode='text', 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_.
@@ -47,6 +79,9 @@ function edit.initialize_state(top, left, right, font_height, line_height)  -- c
     cursor_x = 0,
     cursor_y = 0,
 
+    current_drawing_mode = 'line',
+    previous_drawing_mode = nil,  -- extra state for some ephemeral modes like moving/deleting/naming points
+
     font_height = font_height,
     line_height = line_height,
     em = App.newText(love.graphics.getFont(), 'm'),  -- widest possible character width
@@ -71,6 +106,15 @@ function edit.initialize_state(top, left, right, font_height, line_height)  -- c
   return result
 end  -- App.initialize_state
 
+function edit.fixup_cursor(State)
+  for i,line in ipairs(State.lines) do
+    if line.mode == 'text' then
+      State.cursor1.line = i
+      break
+    end
+  end
+end
+
 function edit.draw(State)
   State.button_handlers = {}
   App.color(Text_color)
@@ -85,21 +129,46 @@ function edit.draw(State)
 --?   print('== draw')
   for line_index = State.screen_top1.line,#State.lines do
     local line = State.lines[line_index]
---?     print('draw:', y, line_index, line)
+--?     print('draw:', y, line_index, line, line.mode)
     if y + State.line_height > App.screen.height then break end
     State.screen_bottom1 = {line=line_index, pos=nil, posB=nil}
+    if line.mode == 'text' then
 --?     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
+      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
+      if line.data == '' then
+        -- button to insert new drawing
+        button(State, 'draw', {x=4,y=y+4, w=12,h=12, color={1,1,0},
+          icon = icon.insert_drawing,
+          onpress1 = function()
+                       Drawing.before = snapshot(State, line_index-1, line_index)
+                       table.insert(State.lines, line_index, {mode='drawing', y=y, h=256/2, points={}, shapes={}, pending={}})
+                       table.insert(State.line_cache, line_index, {})
+                       if State.cursor1.line >= line_index then
+                         State.cursor1.line = State.cursor1.line+1
+                       end
+                       schedule_save(State)
+                       record_undo_event(State, {before=Drawing.before, after=snapshot(State, line_index-1, line_index+1)})
+                     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)
+    elseif line.mode == 'drawing' then
+      y = y+Drawing_padding_top
+      Drawing.draw(State, line_index, y)
+      y = y + Drawing.pixels(line.h, State.width) + Drawing_padding_bottom
+    else
+      print(line.mode)
+      assert(false)
     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)
@@ -107,6 +176,7 @@ function edit.draw(State)
 end
 
 function edit.update(State, dt)
+  Drawing.update(State, dt)
   if State.next_save and State.next_save < App.getTime() then
     save_to_disk(State)
     State.next_save = nil
@@ -128,23 +198,44 @@ 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)
+--?   print('press')
   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
+    if line.mode == 'text' then
+      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
+    elseif line.mode == 'drawing' then
+      local line_cache = State.line_cache[line_index]
+      if Drawing.in_drawing(line, line_cache, x, y, State.left,State.right) then
+        State.lines.current_drawing_index = line_index
+        State.lines.current_drawing = line
+        Drawing.before = snapshot(State, line_index)
+        Drawing.mouse_pressed(State, line_index, x,y, mouse_button)
+        break
+      end
     end
   end
 end
 
 function edit.mouse_released(State, x,y, mouse_button)
+  if State.search_term then return end
+--?   print('release')
+  if State.lines.current_drawing then
+    Drawing.mouse_released(State, x,y, mouse_button)
+    schedule_save(State)
+    if Drawing.before then
+      record_undo_event(State, {before=Drawing.before, after=snapshot(State, State.lines.current_drawing_index)})
+      Drawing.before = nil
+    end
+  end
 end
 
 function edit.textinput(State, t)
@@ -153,6 +244,12 @@ function edit.textinput(State, t)
     State.search_term = State.search_term..t
     State.search_text = nil
     Text.search_next(State)
+  elseif State.current_drawing_mode == 'name' then
+    local before = snapshot(State, State.lines.current_drawing_index)
+    local drawing = State.lines.current_drawing
+    local p = drawing.points[drawing.pending.target_point]
+    p.name = p.name..t
+    record_undo_event(State, {before=before, after=snapshot(State, State.lines.current_drawing_index)})
   else
     Text.textinput(State, t)
   end
@@ -205,7 +302,7 @@ function edit.keychord_pressed(State, chord, key)
       end
       edit.eradicate_locations_after_the_fold(State)
     end
-  elseif chord == 'C-d' then
+  elseif chord == 'C-i' then
     if State.cursor1.posB == nil then
       local before = snapshot(State, State.cursor1.line)
       if State.lines[State.cursor1.line].dataB == nil then
@@ -240,6 +337,8 @@ function edit.keychord_pressed(State, chord, key)
       State.cursor1 = deepcopy(src.cursor)
       patch(State.lines, event.after, event.before)
       patch_placeholders(State.line_cache, event.after, event.before)
+      -- invalidate various cached bits of lines
+      State.lines.current_drawing = nil
       -- if we're scrolling, reclaim all fragments to avoid memory leaks
       Text.redraw_all(State)
       schedule_save(State)
@@ -252,6 +351,8 @@ function edit.keychord_pressed(State, chord, key)
       State.screen_top1 = deepcopy(src.screen_top)
       State.cursor1 = deepcopy(src.cursor)
       patch(State.lines, event.before, event.after)
+      -- invalidate various cached bits of lines
+      State.lines.current_drawing = nil
       -- if we're scrolling, reclaim all fragments to avoid memory leaks
       Text.redraw_all(State)
       schedule_save(State)
@@ -289,7 +390,42 @@ function edit.keychord_pressed(State, chord, key)
     end
     schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
-  -- dispatch to text
+  -- dispatch to drawing or text
+  elseif App.mouse_down(1) or chord:sub(1,2) == 'C-' then
+    -- DON'T reset line_cache.starty here
+    local drawing_index, drawing = Drawing.current_drawing(State)
+    if drawing_index then
+      local before = snapshot(State, drawing_index)
+      Drawing.keychord_pressed(State, chord)
+      record_undo_event(State, {before=before, after=snapshot(State, drawing_index)})
+      schedule_save(State)
+    end
+  elseif chord == 'escape' and not App.mouse_down(1) then
+    for _,line in ipairs(State.lines) do
+      if line.mode == 'drawing' then
+        line.show_help = false
+      end
+    end
+  elseif State.current_drawing_mode == 'name' then
+    if chord == 'return' then
+      State.current_drawing_mode = State.previous_drawing_mode
+      State.previous_drawing_mode = nil
+    else
+      local before = snapshot(State, State.lines.current_drawing_index)
+      local drawing = State.lines.current_drawing
+      local p = drawing.points[drawing.pending.target_point]
+      if chord == 'escape' then
+        p.name = nil
+        record_undo_event(State, {before=before, after=snapshot(State, State.lines.current_drawing_index)})
+      elseif chord == 'backspace' then
+        local len = utf8.len(p.name)
+        local byte_offset = Text.offset(p.name, len-1)
+        if len == 1 then byte_offset = 0 end
+        p.name = string.sub(p.name, 1, byte_offset)
+        record_undo_event(State, {before=before, after=snapshot(State, State.lines.current_drawing_index)})
+      end
+    end
+    schedule_save(State)
   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)
diff --git a/source_file.lua b/source_file.lua
index 9c0b8a4..6552667 100644
--- a/source_file.lua
+++ b/source_file.lua
@@ -25,17 +25,21 @@ function load_from_file(infile)
     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..']*)')
+      if line == '```lines' then  -- inflexible with whitespace since these files are always autogenerated
+        table.insert(result, load_drawing(infile_next_line))
       else
-        line_info.data = line
+        local line_info = {mode='text'}
+        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
-      table.insert(result, line_info)
     end
   end
   if #result == 0 then
-    table.insert(result, {data=''})
+    table.insert(result, {mode='text', data=''})
   end
   return result
 end
@@ -46,16 +50,86 @@ function save_to_disk(State)
     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)
+    if line.mode == 'drawing' then
+      store_drawing(outfile, line)
+    else
+      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:write('\n')
   end
   outfile:close()
 end
 
+function load_drawing(infile_next_line)
+  local drawing = {mode='drawing', h=256/2, points={}, shapes={}, pending={}}
+  while true do
+    local line = infile_next_line()
+    assert(line)
+    if line == '```' then break end
+    local shape = json.decode(line)
+    if shape.mode == 'freehand' then
+      -- no changes needed
+    elseif shape.mode == 'line' or shape.mode == 'manhattan' then
+      local name = shape.p1.name
+      shape.p1 = Drawing.find_or_insert_point(drawing.points, shape.p1.x, shape.p1.y, --[[large width to minimize overlap]] 1600)
+      drawing.points[shape.p1].name = name
+      name = shape.p2.name
+      shape.p2 = Drawing.find_or_insert_point(drawing.points, shape.p2.x, shape.p2.y, --[[large width to minimize overlap]] 1600)
+      drawing.points[shape.p2].name = name
+    elseif shape.mode == 'polygon' or shape.mode == 'rectangle' or shape.mode == 'square' then
+      for i,p in ipairs(shape.vertices) do
+        local name = p.name
+        shape.vertices[i] = Drawing.find_or_insert_point(drawing.points, p.x,p.y, --[[large width to minimize overlap]] 1600)
+        drawing.points[shape.vertices[i]].name = name
+      end
+    elseif shape.mode == 'circle' or shape.mode == 'arc' then
+      local name = shape.center.name
+      shape.center = Drawing.find_or_insert_point(drawing.points, shape.center.x,shape.center.y, --[[large width to minimize overlap]] 1600)
+      drawing.points[shape.center].name = name
+    elseif shape.mode == 'deleted' then
+      -- ignore
+    else
+      print(shape.mode)
+      assert(false)
+    end
+    table.insert(drawing.shapes, shape)
+  end
+  return drawing
+end
+
+function store_drawing(outfile, drawing)
+  outfile:write('```lines\n')
+  for _,shape in ipairs(drawing.shapes) do
+    if shape.mode == 'freehand' then
+      outfile:write(json.encode(shape), '\n')
+    elseif shape.mode == 'line' or shape.mode == 'manhattan' then
+      local line = json.encode({mode=shape.mode, p1=drawing.points[shape.p1], p2=drawing.points[shape.p2]})
+      outfile:write(line, '\n')
+    elseif shape.mode == 'polygon' or shape.mode == 'rectangle' or shape.mode == 'square' then
+      local obj = {mode=shape.mode, vertices={}}
+      for _,p in ipairs(shape.vertices) do
+        table.insert(obj.vertices, drawing.points[p])
+      end
+      local line = json.encode(obj)
+      outfile:write(line, '\n')
+    elseif shape.mode == 'circle' then
+      outfile:write(json.encode({mode=shape.mode, center=drawing.points[shape.center], radius=shape.radius}), '\n')
+    elseif shape.mode == 'arc' then
+      outfile:write(json.encode({mode=shape.mode, center=drawing.points[shape.center], radius=shape.radius, start_angle=shape.start_angle, end_angle=shape.end_angle}), '\n')
+    elseif shape.mode == 'deleted' then
+      -- ignore
+    else
+      print(shape.mode)
+      assert(false)
+    end
+  end
+  outfile:write('```\n')
+end
+
 -- for tests
 function load_array(a)
   local result = {}
@@ -64,16 +138,64 @@ function load_array(a)
   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..']*)')
+--?     print(line)
+    if line == '```lines' then  -- inflexible with whitespace since these files are always autogenerated
+--?       print('inserting drawing')
+      i, drawing = load_drawing_from_array(next_line, a, i)
+--?       print('i now', i)
+      table.insert(result, drawing)
     else
-      line_info.data = line
+--?       print('inserting text')
+      local line_info = {mode='text'}
+      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
-    table.insert(result, line_info)
   end
   if #result == 0 then
-    table.insert(result, {data=''})
+    table.insert(result, {mode='text', data=''})
   end
   return result
 end
+
+function load_drawing_from_array(iter, a, i)
+  local drawing = {mode='drawing', h=256/2, points={}, shapes={}, pending={}}
+  local line
+  while true do
+    i, line = iter(a, i)
+    assert(i)
+--?     print(i)
+    if line == '```' then break end
+    local shape = json.decode(line)
+    if shape.mode == 'freehand' then
+      -- no changes needed
+    elseif shape.mode == 'line' or shape.mode == 'manhattan' then
+      local name = shape.p1.name
+      shape.p1 = Drawing.find_or_insert_point(drawing.points, shape.p1.x, shape.p1.y, --[[large width to minimize overlap]] 1600)
+      drawing.points[shape.p1].name = name
+      name = shape.p2.name
+      shape.p2 = Drawing.find_or_insert_point(drawing.points, shape.p2.x, shape.p2.y, --[[large width to minimize overlap]] 1600)
+      drawing.points[shape.p2].name = name
+    elseif shape.mode == 'polygon' or shape.mode == 'rectangle' or shape.mode == 'square' then
+      for i,p in ipairs(shape.vertices) do
+        local name = p.name
+        shape.vertices[i] = Drawing.find_or_insert_point(drawing.points, p.x,p.y, --[[large width to minimize overlap]] 1600)
+        drawing.points[shape.vertices[i]].name = name
+      end
+    elseif shape.mode == 'circle' or shape.mode == 'arc' then
+      local name = shape.center.name
+      shape.center = Drawing.find_or_insert_point(drawing.points, shape.center.x,shape.center.y, --[[large width to minimize overlap]] 1600)
+      drawing.points[shape.center].name = name
+    elseif shape.mode == 'deleted' then
+      -- ignore
+    else
+      print(shape.mode)
+      assert(false)
+    end
+    table.insert(drawing.shapes, shape)
+  end
+  return i, drawing
+end
diff --git a/source_text.lua b/source_text.lua
index e491dac..9c1279a 100644
--- a/source_text.lua
+++ b/source_text.lua
@@ -53,9 +53,6 @@ function Text.draw(State, line_index, y, startpos, startposB)
   -- 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
@@ -196,6 +193,7 @@ end
 
 function Text.populate_screen_line_starting_pos(State, line_index)
   local line = State.lines[line_index]
+  if line.mode ~= 'text' then return end
   local line_cache = State.line_cache[line_index]
   if line_cache.screen_line_starting_pos then
     return
@@ -222,6 +220,7 @@ end
 function Text.compute_fragments(State, line_index)
 --?   print('compute_fragments', line_index, 'between', State.left, State.right)
   local line = State.lines[line_index]
+  if line.mode ~= 'text' then return end
   local line_cache = State.line_cache[line_index]
   if line_cache.fragments then
     return
@@ -416,11 +415,16 @@ function Text.keychord_pressed(State, chord)
       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)
+      if State.lines[State.cursor1.line-1].mode == 'drawing' then
+        table.remove(State.lines, State.cursor1.line-1)
+        table.remove(State.line_cache, State.cursor1.line-1)
+      else
+        -- 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)
+      end
       State.cursor1.line = State.cursor1.line-1
     end
     if State.screen_top1.line > #State.lines then
@@ -471,10 +475,12 @@ function Text.keychord_pressed(State, chord)
         -- 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
+      if State.lines[State.cursor1.line+1].mode == 'text' 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
+      end
       table.remove(State.lines, State.cursor1.line+1)
       table.remove(State.line_cache, State.cursor1.line+1)
     end
@@ -530,7 +536,7 @@ 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.lines, State.cursor1.line+1, {mode='text', 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
@@ -550,7 +556,11 @@ function Text.pageup(State)
   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
+    if State.lines[State.screen_top1.line].mode == 'text' then
+      y = y - State.line_height
+    elseif State.lines[State.screen_top1.line].mode == 'drawing' then
+      y = y - Drawing_padding_height - Drawing.pixels(State.lines[State.screen_top1.line].h, State.width)
+    end
     top2 = Text.previous_screen_line(State, top2)
   end
   State.screen_top1 = Text.to1(State, top2)
@@ -567,7 +577,7 @@ function Text.pagedown(State)
   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}
+    State.screen_top1 = {line=State.screen_bottom1.line, pos=State.screen_bottom1.pos, posB=State.screen_bottom1.posB}
   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}
@@ -578,6 +588,7 @@ function Text.pagedown(State)
 end
 
 function Text.up(State)
+  assert(State.lines[State.cursor1.line].mode == 'text')
   if State.cursor1.pos then
     Text.upA(State)
   else
@@ -591,18 +602,23 @@ function Text.upA(State)
   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
+    local new_cursor_line = State.cursor1.line
+    while new_cursor_line > 1 do
+      new_cursor_line = new_cursor_line-1
+      if State.lines[new_cursor_line].mode == 'text' 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
+        break
+      end
     end
   else
     -- move up one screen line in current line
@@ -626,15 +642,19 @@ function Text.upB(State)
   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
+    local new_cursor_line = State.cursor1.line
+    while new_cursor_line > 1 do
+      new_cursor_line = new_cursor_line-1
+      if State.lines[new_cursor_line].mode == 'text' then
+        State.cursor1 = {line=State.cursor1.line-1, 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
+        break
+      end
     end
   elseif screen_line_indexB == 2 then
     -- all-B screen-line to potentially A+B screen-line
@@ -673,16 +693,22 @@ end
 -- 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)
+  assert(State.lines[State.cursor1.line].mode == 'text')
 --?   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)
+    local new_cursor_line = State.cursor1.line
+    while new_cursor_line < #State.lines do
+      new_cursor_line = new_cursor_line+1
+      if State.lines[new_cursor_line].mode == 'text' then
+        State.cursor1 = {
+          line = new_cursor_line,
+          pos = Text.nearest_cursor_pos(State.lines[new_cursor_line].data, State.cursor_x, State.left),
+        }
+--?         print(State.cursor1.pos)
+        break
+      end
     end
     if State.cursor1.line > State.screen_bottom1.line then
 --?       print('screen top before:', State.screen_top1.line, State.screen_top1.pos)
@@ -737,7 +763,7 @@ function Text.start_of_line(State)
     State.cursor1.posB = 1
   end
   if Text.lt1(State.cursor1, State.screen_top1) then
-    State.screen_top1 = {line=State.cursor1.line, pos=1}  -- copy
+    State.screen_top1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}  -- copy
   end
 end
 
@@ -919,11 +945,18 @@ 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,
-    }
+  else
+    local new_cursor_line = State.cursor1.line
+    while new_cursor_line > 1 do
+      new_cursor_line = new_cursor_line-1
+      if State.lines[new_cursor_line].mode == 'text' then
+        State.cursor1 = {
+          line = new_cursor_line,
+          pos = utf8.len(State.lines[new_cursor_line].data) + 1,
+        }
+        break
+      end
+    end
   end
   if Text.lt1(State.cursor1, State.screen_top1) then
     local top2 = Text.to2(State, State.screen_top1)
@@ -955,6 +988,7 @@ function Text.right(State)
 end
 
 function Text.right_without_scroll(State)
+  assert(State.lines[State.cursor1.line].mode == 'text')
   if State.cursor1.pos then
     Text.right_without_scrollA(State)
   else
@@ -965,17 +999,31 @@ 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}
+  else
+    local new_cursor_line = State.cursor1.line
+    while new_cursor_line <= #State.lines-1 do
+      new_cursor_line = new_cursor_line+1
+      if State.lines[new_cursor_line].mode == 'text' then
+        State.cursor1 = {line=new_cursor_line, pos=1}
+        break
+      end
+    end
   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
+  else
     -- overflow back into A side
-    State.cursor1 = {line=State.cursor1.line+1, pos=1}
+    local new_cursor_line = State.cursor1.line
+    while new_cursor_line <= #State.lines-1 do
+      new_cursor_line = new_cursor_line+1
+      if State.lines[new_cursor_line].mode == 'text' then
+        State.cursor1 = {line=new_cursor_line, pos=1}
+        break
+      end
+    end
   end
 end
 
@@ -1027,7 +1075,23 @@ function Text.cursor_at_final_screen_line(State)
 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
+  local y = State.top
+  while State.cursor1.line <= #State.lines do
+    if State.lines[State.cursor1.line].mode == 'text' then
+      break
+    end
+--?     print('cursor skips', State.cursor1.line)
+    y = y + Drawing_padding_height + Drawing.pixels(State.lines[State.cursor1.line].h, State.width)
+    State.cursor1.line = State.cursor1.line + 1
+  end
+  -- hack: insert a text line at bottom of file if necessary
+  if State.cursor1.line > #State.lines then
+    assert(State.cursor1.line == #State.lines+1)
+    table.insert(State.lines, {mode='text', data=''})
+    table.insert(State.line_cache, {})
+  end
+--?   print(y, App.screen.height, App.screen.height-State.line_height)
+  if y > App.screen.height - State.line_height then
 --?     print('scroll up')
     Text.snap_cursor_to_bottom_of_screen(State)
   end
@@ -1052,11 +1116,24 @@ function Text.snap_cursor_to_bottom_of_screen(State)
   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
+    if top2.screen_line > 1 or State.lines[top2.line-1].mode == 'text' then
+      local h = State.line_height
+      if y - h < State.top then
+        break
+      end
+      y = y - h
+    else
+      assert(top2.line > 1)
+      assert(State.lines[top2.line-1].mode == 'drawing')
+      -- We currently can't draw partial drawings, so either skip it entirely
+      -- or not at all.
+      local h = Drawing_padding_height + Drawing.pixels(State.lines[top2.line-1].h, State.width)
+      if y - h < State.top then
+        break
+      end
+--?       print('skipping drawing of height', h)
+      y = y - h
     end
-    y = y - h
     top2 = Text.previous_screen_line(State, top2)
   end
 --?   print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
@@ -1064,7 +1141,6 @@ function Text.snap_cursor_to_bottom_of_screen(State)
 --?   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)
@@ -1338,6 +1414,9 @@ function Text.x(s, pos)
 end
 
 function Text.to2(State, loc1)
+  if State.lines[loc1.line].mode == 'drawing' then
+    return {line=loc1.line, screen_line=1, screen_pos=1}
+  end
   if loc1.pos then
     return Text.to2A(State, loc1)
   else
@@ -1448,10 +1527,8 @@ 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)
diff --git a/source_text_tests.lua b/source_text_tests.lua
index ecffb13..89ad1ce 100644
--- a/source_text_tests.lua
+++ b/source_text_tests.lua
@@ -14,6 +14,34 @@ function test_initial_state()
   check_eq(Editor_state.screen_top1.pos, 1, 'F - test_initial_state/screen_top:pos')
 end
 
+function test_click_to_create_drawing()
+  io.write('\ntest_click_to_create_drawing')
+  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_mouse_click(Editor_state, 8,Editor_state.top+8, 1)
+  -- cursor skips drawing to always remain on text
+  check_eq(#Editor_state.lines, 2, 'F - test_click_to_create_drawing/#lines')
+  check_eq(Editor_state.cursor1.line, 2, 'F - test_click_to_create_drawing/cursor')
+end
+
+function test_backspace_to_delete_drawing()
+  io.write('\ntest_backspace_to_delete_drawing')
+  -- display a drawing followed by a line of text (you shouldn't ever have a drawing right at the end)
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'```lines', '```', ''}
+  Text.redraw_all(Editor_state)
+  -- cursor is on text as always (outside tests this will get initialized correctly)
+  Editor_state.cursor1.line = 2
+  -- backspacing deletes the drawing
+  edit.run_after_keychord(Editor_state, 'backspace')
+  check_eq(#Editor_state.lines, 1, 'F - test_backspace_to_delete_drawing/#lines')
+  check_eq(Editor_state.cursor1.line, 1, 'F - test_backspace_to_delete_drawing/cursor')
+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
@@ -695,6 +723,36 @@ function test_pagedown()
   App.screen.check(y, 'ghi', 'F - test_pagedown/screen:2')
 end
 
+function test_pagedown_skips_drawings()
+  io.write('\ntest_pagedown_skips_drawings')
+  -- some lines of text with a drawing intermixed
+  local drawing_width = 50
+  App.screen.init{width=Editor_state.left+drawing_width, height=80}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc',               -- height 15
+                                  '```lines', '```',   -- height 25
+                                  'def',               -- height 15
+                                  'ghi'}               -- height 15
+  Text.redraw_all(Editor_state)
+  check_eq(Editor_state.lines[2].mode, 'drawing', 'F - test_pagedown_skips_drawings/baseline/lines')
+  Editor_state.cursor1 = {line=1, pos=1}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  Editor_state.screen_bottom1 = {}
+  local drawing_height = Drawing_padding_height + drawing_width/2  -- default
+  -- initially the screen displays the first line and the drawing
+  -- 15px margin + 15px line1 + 10px margin + 25px drawing + 10px margin = 75px < screen height 80px
+  edit.draw(Editor_state)
+  local y = Editor_state.top
+  App.screen.check(y, 'abc', 'F - test_pagedown_skips_drawings/baseline/screen:1')
+  -- after pagedown the screen draws the drawing up top
+  -- 15px margin + 10px margin + 25px drawing + 10px margin + 15px line3 = 75px < screen height 80px
+  edit.run_after_keychord(Editor_state, 'pagedown')
+  check_eq(Editor_state.screen_top1.line, 2, 'F - test_pagedown_skips_drawings/screen_top')
+  check_eq(Editor_state.cursor1.line, 3, 'F - test_pagedown_skips_drawings/cursor')
+  y = Editor_state.top + drawing_height
+  App.screen.check(y, 'def', 'F - test_pagedown_skips_drawings/screen:1')
+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
@@ -1527,7 +1585,7 @@ 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'}
+  Editor_state.lines = load_array{'```lines', '```', 'def', 'ghi', 'deg'}
   Text.redraw_all(Editor_state)
   Editor_state.cursor1 = {line=1, pos=1}
   Editor_state.screen_top1 = {line=1, pos=1}
diff --git a/source_undo.lua b/source_undo.lua
index 0aa6755..6023324 100644
--- a/source_undo.lua
+++ b/source_undo.lua
@@ -50,6 +50,8 @@ function snapshot(State, s,e)
     screen_top=deepcopy(State.screen_top1),
     selection=deepcopy(State.selection1),
     cursor=deepcopy(State.cursor1),
+    current_drawing_mode=Drawing_mode,
+    previous_drawing_mode=State.previous_drawing_mode,
     lines={},
     start_line=s,
     end_line=e,
@@ -58,7 +60,19 @@ function snapshot(State, s,e)
   -- 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})
+    if line.mode == 'text' then
+      table.insert(event.lines, {mode='text', data=line.data, dataB=line.dataB})
+    elseif line.mode == 'drawing' then
+      local points=deepcopy(line.points)
+--?       print('copying', line.points, 'with', #line.points, 'points into', points)
+      local shapes=deepcopy(line.shapes)
+--?       print('copying', line.shapes, 'with', #line.shapes, 'shapes into', shapes)
+      table.insert(event.lines, {mode='drawing', h=line.h, points=points, shapes=shapes, pending={}})
+--?       table.insert(event.lines, {mode='drawing', h=line.h, points=deepcopy(line.points), shapes=deepcopy(line.shapes), pending={}})
+    else
+      print(line.mode)
+      assert(false)
+    end
   end
   return event
 end
diff --git a/text.lua b/text.lua
index d6d44a4..06ba584 100644
--- a/text.lua
+++ b/text.lua
@@ -113,7 +113,7 @@ function Text.compute_fragments(State, line_index)
   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)..'; '..tostring(State.right-x)..'px to go')
+--?     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
@@ -356,8 +356,7 @@ function Text.insert_return(State)
   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)
   Text.clear_screen_line_cache(State, State.cursor1.line)
-  State.cursor1.line = State.cursor1.line+1
-  State.cursor1.pos = 1
+  State.cursor1 = {line=State.cursor1.line+1, pos=1}
 end
 
 function Text.pageup(State)
@@ -373,8 +372,7 @@ function Text.pageup(State)
     top2 = Text.previous_screen_line(State, top2)
   end
   State.screen_top1 = Text.to1(State, top2)
-  State.cursor1.line = State.screen_top1.line
-  State.cursor1.pos = State.screen_top1.pos
+  State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
   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')
@@ -393,12 +391,10 @@ function Text.pagedown(State)
   if Text.lt1(State.screen_top1, new_top1) then
     State.screen_top1 = new_top1
   else
-    State.screen_top1.line = State.screen_bottom1.line
-    State.screen_top1.pos = State.screen_bottom1.pos
+    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
-  State.cursor1.pos = State.screen_top1.pos
+  State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
   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
@@ -463,6 +459,7 @@ function Text.down(State)
     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)
@@ -608,8 +605,12 @@ end
 
 -- should never modify State.cursor1
 function Text.snap_cursor_to_bottom_of_screen(State)
+--?   print('to2:', State.cursor1.line, State.cursor1.pos)
   local top2 = Text.to2(State, State.cursor1)
+--?   print('to2: =>', top2.line, top2.screen_line, top2.screen_pos)
+  -- slide to start of screen line
   top2.screen_pos = 1  -- start of screen line
+--?   print('snap', State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
 --?   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
@@ -626,6 +627,7 @@ function Text.snap_cursor_to_bottom_of_screen(State)
 --?   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.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
   Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
 end
 
@@ -782,7 +784,7 @@ function Text.x(s, pos)
 end
 
 function Text.to2(State, loc1)
-  local result = {line=loc1.line, screen_line=1}
+  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