about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorKartik K. Agaram <vc@akkartik.com>2022-09-05 11:28:03 -0700
committerKartik K. Agaram <vc@akkartik.com>2022-09-05 11:29:39 -0700
commit528c64d690c2d1cb4b3b70c37008b3ba37d904b9 (patch)
tree6a05d6d291276b5a3c63475686e02648f3091da6
parent9f94470f9dd56c03c66d98bbeff2fc60995cc0ae (diff)
downloadtext.love-528c64d690c2d1cb4b3b70c37008b3ba37d904b9.tar.gz
support drawings in the source editor
-rw-r--r--commands.lua2
-rw-r--r--edit.lua8
-rw-r--r--file.lua3
-rw-r--r--main.lua12
-rw-r--r--source.lua2
-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.lua39
11 files changed, 545 insertions, 128 deletions
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/edit.lua b/edit.lua
index 87cf6c2..29f28d0 100644
--- a/edit.lua
+++ b/edit.lua
@@ -134,7 +134,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}
     if line.mode == 'text' then
 --?       print('text.draw', y, line_index)
       local startpos = 1
@@ -169,7 +169,6 @@ function edit.draw(State)
       assert(false)
     end
   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
@@ -331,7 +330,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 53ef047..6c460a9 100644
--- a/file.lua
+++ b/file.lua
@@ -44,7 +44,8 @@ function save_to_disk(State)
     if line.mode == 'drawing' then
       store_drawing(outfile, line)
     else
-      outfile:write(line.data, '\n')
+      outfile:write(line.data)
+      outfile:write('\n')
     end
   end
   outfile:close()
diff --git a/main.lua b/main.lua
index 7b2da57..dad1723 100644
--- a/main.lua
+++ b/main.lua
@@ -24,6 +24,13 @@ load_file_from_source_or_save_directory('button.lua')
 -- both sides require (different parts of) the logging framework
 load_file_from_source_or_save_directory('log.lua')
 
+-- both sides use drawings
+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')
+
 -- but some files we want to only load sometimes
 function App.load()
   if love.filesystem.getInfo('config') then
@@ -43,13 +50,8 @@ function App.load()
         load_file_from_source_or_save_directory('search.lua')
         load_file_from_source_or_save_directory('select.lua')
         load_file_from_source_or_save_directory('undo.lua')
-      load_file_from_source_or_save_directory('icons.lua')
       load_file_from_source_or_save_directory('text_tests.lua')
     load_file_from_source_or_save_directory('run_tests.lua')
-    load_file_from_source_or_save_directory('drawing.lua')
-      load_file_from_source_or_save_directory('geom.lua')
-      load_file_from_source_or_save_directory('help.lua')
-    load_file_from_source_or_save_directory('drawing_tests.lua')
   else
     load_file_from_source_or_save_directory('source_file.lua')
     load_file_from_source_or_save_directory('source.lua')
diff --git a/source.lua b/source.lua
index e23cbc6..7ec1aae 100644
--- a/source.lua
+++ b/source.lua
@@ -277,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 c2c633b..6be260d 100644
--- a/text.lua
+++ b/text.lua
@@ -115,7 +115,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
@@ -365,8 +365,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)
@@ -386,8 +385,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')
@@ -406,12 +404,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
@@ -430,7 +426,7 @@ function Text.up(State)
       new_cursor_line = new_cursor_line-1
       if State.lines[new_cursor_line].mode == 'text' then
 --?         print('found previous text line')
-        State.cursor1.line = new_cursor_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')
@@ -469,8 +465,10 @@ function Text.down(State)
     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
-        State.cursor1.pos = Text.nearest_cursor_pos(State.lines[State.cursor1.line].data, State.cursor_x, State.left)
+        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
@@ -486,6 +484,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)
@@ -582,8 +581,10 @@ function Text.left(State)
     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
-        State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + 1
+        State.cursor1 = {
+          line = new_cursor_line,
+          pos = utf8.len(State.lines[new_cursor_line].data) + 1,
+        }
         break
       end
     end
@@ -611,8 +612,7 @@ function Text.right_without_scroll(State)
     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
-        State.cursor1.pos = 1
+        State.cursor1 = {line=new_cursor_line, pos=1}
         break
       end
     end
@@ -663,8 +663,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
@@ -694,6 +698,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
 
@@ -853,7 +858,7 @@ function Text.to2(State, loc1)
   if State.lines[loc1.line].mode == 'drawing' then
     return {line=loc1.line, screen_line=1, screen_pos=1}
   end
-  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