about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--README.md14
-rw-r--r--app.lua6
-rw-r--r--conf.lua3
-rw-r--r--drawing.lua6
-rw-r--r--edit.lua50
-rw-r--r--file.lua2
-rw-r--r--keychord.lua4
-rw-r--r--log.lua50
-rw-r--r--log_browser.lua6
-rw-r--r--main.lua29
-rw-r--r--reference.md3
-rw-r--r--run.lua17
-rw-r--r--search.lua2
-rw-r--r--select.lua14
-rw-r--r--source.lua27
-rw-r--r--source_edit.lua66
-rw-r--r--source_select.lua6
-rw-r--r--source_text.lua63
-rw-r--r--source_text_tests.lua35
-rw-r--r--source_undo.lua29
-rw-r--r--text.lua71
-rw-r--r--text_tests2
-rw-r--r--text_tests.lua35
-rw-r--r--undo.lua26
24 files changed, 292 insertions, 274 deletions
diff --git a/README.md b/README.md
index 27ce06d..0887f4a 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
 # An editor for plain text.
 
+[![0 dependencies!](https://0dependencies.dev/0dependencies.svg)](https://0dependencies.dev)
+
 Not very useful by itself, but it's a fork of [lines.love](http://akkartik.name/lines.html)
 that you can take in other directions besides line drawings, while easily
 sharing patches between forks.
@@ -10,8 +12,7 @@ modifications break something.
 ## Getting started
 
 Install [LÖVE](https://love2d.org). It's just a 5MB download, open-source and
-extremely well-behaved. I'll assume below that you can invoke it using the
-`love` command, but that might vary depending on your OS.
+extremely well-behaved.
 
 To run from the terminal, [pass this directory to LÖVE](https://love2d.org/wiki/Getting_Started#Running_Games),
 optionally with a file path to edit.
@@ -47,12 +48,6 @@ found anything amiss: http://akkartik.name/contact
 
 * No support yet for right-to-left languages.
 
-* Undo/redo may be sluggish in large files. Large files may grow sluggish in
-  other ways. Works well in all circumstances with files under 50KB.
-
-* If you kill the process, say by force-quitting because things things get
-  sluggish, you can lose data.
-
 * Can't scroll while selecting text with mouse.
 
 * No scrollbars yet. That stuff is hard.
@@ -90,4 +85,5 @@ Further forks are encouraged. If you show me your fork, I'll link to it here.
 
 ## Feedback
 
-[Most appreciated.](http://akkartik.name/contact)
+[Most appreciated.](http://akkartik.name/contact) Messages, PRs, patches,
+forks, it's all good.
diff --git a/app.lua b/app.lua
index 263518c..2375ff9 100644
--- a/app.lua
+++ b/app.lua
@@ -131,7 +131,7 @@ function App.run_tests()
   end
   table.sort(sorted_names)
 --?   App.initialize_for_test() -- debug: run a single test at a time like these 2 lines
---?   test_click_below_all_lines()
+--?   test_search()
   for _,name in ipairs(sorted_names) do
     App.initialize_for_test()
 --?     print('=== '..name)
@@ -428,9 +428,9 @@ function App.disable_tests()
       -- love.keyboard.isDown doesn't work on Android, so emulate it using
       -- keypressed and keyreleased events
       if name == 'keypressed' then
-        love.handlers[name] = function(key, scancode, isrepeat)
+        love.handlers[name] = function(key, scancode, is_repeat)
                                 Keys_down[key] = true
-                                return App.keypressed(key, scancode, isrepeat)
+                                return App.keypressed(key, scancode, is_repeat)
                               end
       elseif name == 'keyreleased' then
         love.handlers[name] = function(key, scancode)
diff --git a/conf.lua b/conf.lua
new file mode 100644
index 0000000..d9878da
--- /dev/null
+++ b/conf.lua
@@ -0,0 +1,3 @@
+function love.conf(t)
+  t.identity = 'text'
+end
diff --git a/drawing.lua b/drawing.lua
index 92e3d5f..b74855a 100644
--- a/drawing.lua
+++ b/drawing.lua
@@ -221,7 +221,7 @@ function Drawing.in_drawing(State, line_index, x,y, left,right)
   return y >= starty and y < starty + Drawing.pixels(drawing.h, width) and x >= left and x < right
 end
 
-function Drawing.mouse_press(State, drawing_index, x,y, mouse_button)
+function Drawing.mouse_press(State, drawing_index, x,y, mouse_button, is_touch, presses)
   local drawing = State.lines[drawing_index]
   local starty = Text.starty(State, drawing_index)
   local cx = Drawing.coord(x-State.left, State.width)
@@ -300,7 +300,7 @@ function Drawing.relax_constraints(drawing, p)
   end
 end
 
-function Drawing.mouse_release(State, x,y, mouse_button)
+function Drawing.mouse_release(State, x,y, mouse_button, is_touch, presses)
   if State.current_drawing_mode == 'move' then
     State.current_drawing_mode = State.previous_drawing_mode
     State.previous_drawing_mode = nil
@@ -396,7 +396,7 @@ function Drawing.mouse_release(State, x,y, mouse_button)
   end
 end
 
-function Drawing.keychord_press(State, chord)
+function Drawing.keychord_press(State, chord, key, scancode, is_repeat)
   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
diff --git a/edit.lua b/edit.lua
index eaa415e..edfcb20 100644
--- a/edit.lua
+++ b/edit.lua
@@ -1,7 +1,7 @@
 -- some constants people might like to tweak
 Text_color = {r=0, g=0, b=0}
 Cursor_color = {r=1, g=0, b=0}
-Highlight_color = {r=0.7, g=0.7, b=0.9}  -- selected text
+Highlight_color = {r=0.7, g=0.7, b=0.9, a=0.4}  -- selected text
 
 Margin_top = 15
 Margin_left = 25
@@ -97,7 +97,6 @@ function edit.invalid_cursor1(State)
   return cursor1.pos > #State.lines[cursor1.line].data + 1
 end
 
--- return y drawn until
 function edit.draw(State)
   love.graphics.setFont(State.font)
   App.color(Text_color)
@@ -122,7 +121,6 @@ function edit.draw(State)
   if State.search_term then
     Text.draw_search_bar(State)
   end
-  return y
 end
 
 function edit.update(State, dt)
@@ -147,8 +145,7 @@ function edit.quit(State)
   end
 end
 
-function edit.mouse_press(State, x,y, mouse_button)
-  love.keyboard.setTextInput(true)  -- bring up keyboard on touch screen
+function edit.mouse_press(State, x,y, mouse_button, is_touch, presses)
   if State.search_term then return end
   State.mouse_down = mouse_button
 --?   print_and_log(('edit.mouse_press: cursor at %d,%d'):format(State.cursor1.line, State.cursor1.pos))
@@ -190,15 +187,15 @@ function edit.mouse_press(State, x,y, mouse_button)
   State.old_cursor1 = State.cursor1
   State.old_selection1 = State.selection1
   State.mousepress_shift = App.shift_down()
-  State.selection1 = Text.final_text_loc_on_screen(State)
+  State.selection1 = Text.final_loc_on_screen(State)
 end
 
-function edit.mouse_release(State, x,y, mouse_button)
+function edit.mouse_release(State, x,y, mouse_button, is_touch, presses)
   if State.search_term then return end
 --?   print_and_log(('edit.mouse_release(%d,%d): cursor at %d,%d'):format(x,y, State.cursor1.line, State.cursor1.pos))
   State.mouse_down = nil
   if y < State.top then
-    State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+    State.cursor1 = deepcopy(State.screen_top1)
     edit.clean_up_mouse_press(State)
     return
   end
@@ -216,7 +213,7 @@ function edit.mouse_release(State, x,y, mouse_button)
   end
 
   -- still here? mouse release is below all screen lines
-  State.cursor1 = Text.final_text_loc_on_screen(State)
+  State.cursor1 = Text.final_loc_on_screen(State)
   edit.clean_up_mouse_press(State)
 --?   print_and_log(('edit.mouse_release: finally selection %s,%s cursor %d,%d'):format(tostring(State.selection1.line), tostring(State.selection1.pos), State.cursor1.line, State.cursor1.pos))
 end
@@ -237,7 +234,7 @@ end
 
 function edit.mouse_wheel_move(State, dx,dy)
   if dy > 0 then
-    State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+    State.cursor1 = deepcopy(State.screen_top1)
     for i=1,math.floor(dy) do
       Text.up(State)
     end
@@ -260,13 +257,13 @@ function edit.text_input(State, t)
   schedule_save(State)
 end
 
-function edit.keychord_press(State, chord, key)
+function edit.keychord_press(State, chord, key, scancode, is_repeat)
   if State.selection1.line and
       -- printable character created using shift key => delete selection
       -- (we're not creating any ctrl-shift- or alt-shift- combinations using regular/printable keys)
       (not App.shift_down() or utf8.len(key) == 1) and
       chord ~= 'C-a' and chord ~= 'C-c' and chord ~= 'C-x' and chord ~= 'backspace' and chord ~= 'delete' and chord ~= 'C-z' and chord ~= 'C-y' and not App.is_cursor_movement(key) then
-    Text.delete_selection(State, State.left, State.right)
+    Text.delete_selection_and_record_undo_event(State)
   end
   if State.search_term then
     if chord == 'escape' then
@@ -274,7 +271,7 @@ function edit.keychord_press(State, chord, key)
       State.cursor1 = State.search_backup.cursor
       State.screen_top1 = State.search_backup.screen_top
       State.search_backup = nil
-      Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
     elseif chord == 'return' then
       State.search_term = nil
       State.search_backup = nil
@@ -282,9 +279,14 @@ function edit.keychord_press(State, chord, key)
       local len = utf8.len(State.search_term)
       local byte_offset = Text.offset(State.search_term, len)
       State.search_term = string.sub(State.search_term, 1, byte_offset-1)
-    elseif chord == 'down' then
-      State.cursor1.pos = State.cursor1.pos+1
+      State.cursor = deepcopy(State.search_backup.cursor)
+      State.screen_top = deepcopy(State.search_backup.screen_top)
       Text.search_next(State)
+    elseif chord == 'down' then
+      if #State.search_term > 0 then
+        Text.right(State)
+        Text.search_next(State)
+      end
     elseif chord == 'up' then
       Text.search_previous(State)
     end
@@ -316,9 +318,7 @@ function edit.keychord_press(State, chord, key)
       State.cursor1 = deepcopy(src.cursor)
       State.selection1 = deepcopy(src.selection)
       patch(State.lines, event.after, event.before)
-      patch_placeholders(State.line_cache, event.after, event.before)
-      -- if we're scrolling, reclaim all fragments to avoid memory leaks
-      Text.redraw_all(State)
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
       schedule_save(State)
     end
   elseif chord == 'C-y' then
@@ -329,8 +329,7 @@ function edit.keychord_press(State, chord, key)
       State.cursor1 = deepcopy(src.cursor)
       State.selection1 = deepcopy(src.selection)
       patch(State.lines, event.before, event.after)
-      -- if we're scrolling, reclaim all fragments to avoid memory leaks
-      Text.redraw_all(State)
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
       schedule_save(State)
     end
   -- clipboard
@@ -343,7 +342,7 @@ function edit.keychord_press(State, chord, key)
       App.set_clipboard(s)
     end
   elseif chord == 'C-x' then
-    local s = Text.cut_selection(State, State.left, State.right)
+    local s = Text.cut_selection_and_record_undo_event(State)
     if s then
       App.set_clipboard(s)
     end
@@ -365,20 +364,19 @@ function edit.keychord_press(State, chord, key)
     if Text.cursor_out_of_screen(State) then
       Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
     end
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
-  -- dispatch to text
+    schedule_save(State)
   else
-    Text.keychord_press(State, chord)
+    Text.keychord_press(State, chord, key, scancode, is_repeat)
   end
 end
 
 function edit.key_release(State, key, scancode)
 end
 
-function edit.update_font_settings(State, font_height)
+function edit.update_font_settings(State, font_height, font)
   State.font_height = font_height
-  State.font = love.graphics.newFont(State.font_height)
+  State.font = font or love.graphics.newFont(State.font_height)
   State.line_height = math.floor(font_height*1.3)
 end
 
diff --git a/file.lua b/file.lua
index f7f832b..028ffb4 100644
--- a/file.lua
+++ b/file.lua
@@ -47,7 +47,7 @@ end
 function load_array(a)
   local result = {}
   local next_line = ipairs(a)
-  local i,line,drawing = 0, ''
+  local i,line = 0, ''
   while true do
     i,line = next_line(a, i)
     if i == nil then break end
diff --git a/keychord.lua b/keychord.lua
index f1b6a59..5de899d 100644
--- a/keychord.lua
+++ b/keychord.lua
@@ -2,13 +2,13 @@
 
 Modifiers = {'lctrl', 'rctrl', 'lalt', 'ralt', 'lshift', 'rshift', 'lgui', 'rgui'}
 
-function App.keypressed(key, scancode, isrepeat)
+function App.keypressed(key, scancode, is_repeat)
   if array.find(Modifiers, key) then
     -- do nothing when the modifier is pressed
     return
   end
   -- include the modifier(s) when the non-modifer is pressed
-  App.keychord_press(App.combine_modifiers(key), key)
+  App.keychord_press(App.combine_modifiers(key), key, scancode, is_repeat)
 end
 
 function App.combine_modifiers(key)
diff --git a/log.lua b/log.lua
index 8903e08..4e428d8 100644
--- a/log.lua
+++ b/log.lua
@@ -1,38 +1,38 @@
 function log(stack_frame_index, obj)
-	local info = debug.getinfo(stack_frame_index, 'Sl')
-	local msg
-	if type(obj) == 'string' then
-		msg = obj
-	else
-		msg = json.encode(obj)
-	end
-	love.filesystem.append('log', info.short_src..':'..info.currentline..': '..msg..'\n')
+  local info = debug.getinfo(stack_frame_index, 'Sl')
+  local msg
+  if type(obj) == 'string' then
+    msg = obj
+  else
+    msg = json.encode(obj)
+  end
+  love.filesystem.append('log', info.short_src..':'..info.currentline..': '..msg..'\n')
 end
 
 -- for section delimiters we'll use specific Unicode box characters
 function log_start(name, stack_frame_index)
-	if stack_frame_index == nil then
-		stack_frame_index = 3
-	end
-	-- I'd like to use the unicode character \u{250c} here, but it doesn't work
-	-- in OpenBSD.
-	log(stack_frame_index, '[ u250c ' .. name)
+  if stack_frame_index == nil then
+    stack_frame_index = 3
+  end
+  -- I'd like to use the unicode character \u{250c} here, but it doesn't work
+  -- in OpenBSD.
+  log(stack_frame_index, '[ u250c ' .. name)
 end
 function log_end(name, stack_frame_index)
-	if stack_frame_index == nil then
-		stack_frame_index = 3
-	end
-	-- I'd like to use the unicode character \u{2518} here, but it doesn't work
-	-- in OpenBSD.
-	log(stack_frame_index, '] u2518 ' .. name)
+  if stack_frame_index == nil then
+    stack_frame_index = 3
+  end
+  -- I'd like to use the unicode character \u{2518} here, but it doesn't work
+  -- in OpenBSD.
+  log(stack_frame_index, '] u2518 ' .. name)
 end
 
 function log_new(name, stack_frame_index)
-	if stack_frame_index == nil then
-		stack_frame_index = 4
-	end
-	log_end(name, stack_frame_index)
-	log_start(name, stack_frame_index)
+  if stack_frame_index == nil then
+    stack_frame_index = 4
+  end
+  log_end(name, stack_frame_index)
+  log_start(name, stack_frame_index)
 end
 
 -- rendering graphical objects within sections/boxes
diff --git a/log_browser.lua b/log_browser.lua
index 6e7e6db..fae9d6d 100644
--- a/log_browser.lua
+++ b/log_browser.lua
@@ -183,7 +183,7 @@ end
 function log_browser.quit(State)
 end
 
-function log_browser.mouse_press(State, x,y, mouse_button)
+function log_browser.mouse_press(State, x,y, mouse_button, is_touch, presses)
   local line_index = log_browser.line_index(State, x,y)
   if line_index == nil then
     -- below lower margin
@@ -249,7 +249,7 @@ function log_browser.line_index(State, mx,my)
   end
 end
 
-function log_browser.mouse_release(State, x,y, mouse_button)
+function log_browser.mouse_release(State, x,y, mouse_button, is_touch, presses)
 end
 
 function log_browser.mouse_wheel_move(State, dx,dy)
@@ -267,7 +267,7 @@ end
 function log_browser.text_input(State, t)
 end
 
-function log_browser.keychord_press(State, chord, key)
+function log_browser.keychord_press(State, chord, key, scancode, is_repeat)
   -- move
   if chord == 'up' then
     log_browser.up(State)
diff --git a/main.lua b/main.lua
index 582f05a..83049c2 100644
--- a/main.lua
+++ b/main.lua
@@ -113,16 +113,15 @@ function check_love_version_for_tests()
   end
 end
 
-function App.initialize(arg)
-  love.keyboard.setTextInput(true)  -- bring up keyboard on touch screen
+function App.initialize(arg, unfiltered_arg)
   love.keyboard.setKeyRepeat(true)
 
   love.graphics.setBackgroundColor(1,1,1)
 
   if Current_app == 'run' then
-    run.initialize(arg)
+    run.initialize(arg, unfiltered_arg)
   elseif Current_app == 'source' then
-    source.initialize(arg)
+    source.initialize(arg, unfiltered_arg)
   elseif current_app_is_warning() then
   else
     assert(false, 'unknown app "'..Current_app..'"')
@@ -208,7 +207,7 @@ function App.update(dt)
   end
 end
 
-function App.keychord_press(chord, key)
+function App.keychord_press(chord, key, scancode, is_repeat)
   -- ignore events for some time after window in focus (mostly alt-tab)
   if Current_time < Last_focus_time + 0.01 then
     return
@@ -252,9 +251,9 @@ function App.keychord_press(chord, key)
     return
   end
   if Current_app == 'run' then
-    if run.keychord_press then run.keychord_press(chord, key) end
+    if run.keychord_press then run.keychord_press(chord, key, scancode, is_repeat) end
   elseif Current_app == 'source' then
-    if source.keychord_press then source.keychord_press(chord, key) end
+    if source.keychord_press then source.keychord_press(chord, key, scancode, is_repeat) end
   else
     assert(false, 'unknown app "'..Current_app..'"')
   end
@@ -294,24 +293,24 @@ function App.keyreleased(key, scancode)
   end
 end
 
-function App.mousepressed(x,y, mouse_button)
+function App.mousepressed(x,y, mouse_button, is_touch, presses)
   if current_app_is_warning() then return end
 --?   print('mouse press', x,y)
   if Current_app == 'run' then
-    if run.mouse_press then run.mouse_press(x,y, mouse_button) end
+    if run.mouse_press then run.mouse_press(x,y, mouse_button, is_touch, presses) end
   elseif Current_app == 'source' then
-    if source.mouse_press then source.mouse_press(x,y, mouse_button) end
+    if source.mouse_press then source.mouse_press(x,y, mouse_button, is_touch, presses) end
   else
     assert(false, 'unknown app "'..Current_app..'"')
   end
 end
 
-function App.mousereleased(x,y, mouse_button)
+function App.mousereleased(x,y, mouse_button, is_touch, presses)
   if current_app_is_warning() then return end
   if Current_app == 'run' then
-    if run.mouse_release then run.mouse_release(x,y, mouse_button) end
+    if run.mouse_release then run.mouse_release(x,y, mouse_button, is_touch, presses) end
   elseif Current_app == 'source' then
-    if source.mouse_release then source.mouse_release(x,y, mouse_button) end
+    if source.mouse_release then source.mouse_release(x,y, mouse_button, is_touch, presses) end
   else
     assert(false, 'unknown app "'..Current_app..'"')
   end
@@ -320,9 +319,9 @@ end
 function App.mousemoved(x,y, dx,dy, is_touch)
   if current_app_is_warning() then return end
   if Current_app == 'run' then
-    if run.mouse_move then run.mouse_move(dx,dy) end
+    if run.mouse_move then run.mouse_move(x,y, dx,dy, is_touch) end
   elseif Current_app == 'source' then
-    if source.mouse_move then source.mouse_move(dx,dy) end
+    if source.mouse_move then source.mouse_move(x,y, dx,dy, is_touch) end
   else
     assert(false, 'unknown app "'..Current_app..'"')
   end
diff --git a/reference.md b/reference.md
index 972ab1d..80309c3 100644
--- a/reference.md
+++ b/reference.md
@@ -203,9 +203,6 @@ early warning if you break something.
   `x=right`. Wraps long lines at word boundaries where possible, or in the
   middle of words (no hyphenation yet) when it must.
 
-* `edit.quit()` -- calling this ensures any final edits are flushed to disk
-  before the app exits.
-
 * `edit.draw(state)` -- call this from `App.draw` to display the current
   editor state on the app window as requested in the call to
   `edit.initialize_state` that created `state`.
diff --git a/run.lua b/run.lua
index 307505d..9511726 100644
--- a/run.lua
+++ b/run.lua
@@ -11,7 +11,7 @@ function run.initialize_globals()
 end
 
 -- called only for real run
-function run.initialize(arg)
+function run.initialize(arg, unfiltered_arg)
   log_new('run')
   if Settings then
     run.load_settings()
@@ -100,7 +100,7 @@ function run.initialize_window_geometry()
   App.screen.resize(App.screen.width, App.screen.height, App.screen.flags)
 end
 
-function run.resize(w, h)
+function run.resize(w,h)
 --?   print(("Window resized to width: %d and height: %d."):format(w, h))
   App.screen.width, App.screen.height = w, h
   Text.redraw_all(Editor_state)
@@ -166,14 +166,15 @@ function absolutize(path)
   return path
 end
 
-function run.mouse_press(x,y, mouse_button)
+function run.mouse_press(x,y, mouse_button, is_touch, presses)
   Cursor_time = 0  -- ensure cursor is visible immediately after it moves
-  return edit.mouse_press(Editor_state, x,y, mouse_button)
+  love.keyboard.setTextInput(true)  -- bring up keyboard on touch screen
+  return edit.mouse_press(Editor_state, x,y, mouse_button, is_touch, presses)
 end
 
-function run.mouse_release(x,y, mouse_button)
+function run.mouse_release(x,y, mouse_button, is_touch, presses)
   Cursor_time = 0  -- ensure cursor is visible immediately after it moves
-  return edit.mouse_release(Editor_state, x,y, mouse_button)
+  return edit.mouse_release(Editor_state, x,y, mouse_button, is_touch, presses)
 end
 
 function run.mouse_wheel_move(dx,dy)
@@ -186,9 +187,9 @@ function run.text_input(t)
   return edit.text_input(Editor_state, t)
 end
 
-function run.keychord_press(chord, key)
+function run.keychord_press(chord, key, scancode, is_repeat)
   Cursor_time = 0  -- ensure cursor is visible immediately after it moves
-  return edit.keychord_press(Editor_state, chord, key)
+  return edit.keychord_press(Editor_state, chord, key, scancode, is_repeat)
 end
 
 function run.key_release(key, scancode)
diff --git a/search.lua b/search.lua
index d3a5fea..4aa1f02 100644
--- a/search.lua
+++ b/search.lua
@@ -17,6 +17,7 @@ function Text.draw_search_bar(State)
 end
 
 function Text.search_next(State)
+  if #State.search_term == 0 then return end
   -- search current line from cursor
   local curr_pos = State.cursor1.pos
   local curr_line = State.lines[State.cursor1.line].data
@@ -71,6 +72,7 @@ function Text.search_next(State)
 end
 
 function Text.search_previous(State)
+  if #State.search_term == 0 then return end
   -- search current line before cursor
   local curr_pos = State.cursor1.pos
   local curr_line = State.lines[State.cursor1.line].data
diff --git a/select.lua b/select.lua
index e8df6f9..78affdc 100644
--- a/select.lua
+++ b/select.lua
@@ -1,9 +1,8 @@
 -- helpers for selecting portions of text
 
--- Return any intersection of the region from State.selection1 to State.cursor1 (or
--- current mouse, if mouse is pressed; or recent mouse if mouse is pressed and
--- currently over a drawing) with the region between {line=line_index, pos=apos}
--- and {line=line_index, pos=bpos}.
+-- Return any intersection of the region from State.selection1 to
+-- State.cursor1 (or current mouse, if mouse is pressed) with the region
+-- between {line=line_index, pos=apos} and {line=line_index, pos=bpos}.
 -- apos must be less than bpos. However State.selection1 and State.cursor1 can be in any order.
 -- Result: positions spos,epos between apos,bpos.
 function Text.clip_selection(State, line_index, apos, bpos)
@@ -45,7 +44,6 @@ function Text.clip_selection(State, line_index, apos, bpos)
 end
 
 -- draw highlight for line corresponding to (lo,hi) given an approximate x,y and pos on the same screen line
--- Creates text objects every time, so use this sparingly.
 -- Returns some intermediate computation useful elsewhere.
 function Text.draw_highlight(State, line, x,y, pos, lo,hi)
   if lo then
@@ -81,14 +79,14 @@ function Text.mouse_pos(State)
   return screen_bottom1.line, Text.pos_at_end_of_screen_line(State, screen_bottom1)
 end
 
-function Text.cut_selection(State)
+function Text.cut_selection_and_record_undo_event(State)
   if State.selection1.line == nil then return end
   local result = Text.selection(State)
-  Text.delete_selection(State)
+  Text.delete_selection_and_record_undo_event(State)
   return result
 end
 
-function Text.delete_selection(State)
+function Text.delete_selection_and_record_undo_event(State)
   if State.selection1.line == nil then return end
   local minl,maxl = minmax(State.selection1.line, State.cursor1.line)
   local before = snapshot(State, minl, maxl)
diff --git a/source.lua b/source.lua
index c85517d..bb42440 100644
--- a/source.lua
+++ b/source.lua
@@ -56,7 +56,7 @@ function source.initialize_globals()
 end
 
 -- called only for real run
-function source.initialize()
+function source.initialize(arg, unfiltered_arg)
   log_new('source')
   if Settings and Settings.source then
     source.load_settings()
@@ -173,7 +173,7 @@ function source.initialize_window_geometry()
   App.screen.resize(App.screen.width, App.screen.height, App.screen.flags)
 end
 
-function source.resize(w, h)
+function source.resize(w,h)
 --?   print(("Window resized to width: %d and height: %d."):format(w, h))
   App.screen.width, App.screen.height = w, h
   Text.redraw_all(Editor_state)
@@ -283,14 +283,15 @@ function source.settings()
   }
 end
 
-function source.mouse_press(x,y, mouse_button)
+function source.mouse_press(x,y, mouse_button, is_touch, presses)
   Cursor_time = 0  -- ensure cursor is visible immediately after it moves
+  love.keyboard.setTextInput(true)  -- bring up keyboard on touch screen
 --?   print('mouse click', x, y)
 --?   print(Editor_state.left, Editor_state.right)
 --?   print(Log_browser_state.left, Log_browser_state.right)
   if Show_file_navigator and y < Menu_status_bar_height + File_navigation.num_lines * Editor_state.line_height then
     -- send click to buttons
-    edit.mouse_press(Editor_state, x,y, mouse_button)
+    edit.mouse_press(Editor_state, x,y, mouse_button, is_touch, presses)
     return
   end
   if x < Editor_state.right + Margin_right then
@@ -299,23 +300,23 @@ function source.mouse_press(x,y, mouse_button)
       Focus = 'edit'
       return
     end
-    edit.mouse_press(Editor_state, x,y, mouse_button)
+    edit.mouse_press(Editor_state, x,y, mouse_button, is_touch, presses)
   elseif Show_log_browser_side and Log_browser_state.left <= x and x < Log_browser_state.right then
 --?     print('click on log_browser side')
     if Focus ~= 'log_browser' then
       Focus = 'log_browser'
       return
     end
-    log_browser.mouse_press(Log_browser_state, x,y, mouse_button)
+    log_browser.mouse_press(Log_browser_state, x,y, mouse_button, is_touch, presses)
   end
 end
 
-function source.mouse_release(x,y, mouse_button)
+function source.mouse_release(x,y, mouse_button, is_touch, presses)
   Cursor_time = 0  -- ensure cursor is visible immediately after it moves
   if Focus == 'edit' then
-    return edit.mouse_release(Editor_state, x,y, mouse_button)
+    return edit.mouse_release(Editor_state, x,y, mouse_button, is_touch, presses)
   else
-    return log_browser.mouse_release(Log_browser_state, x,y, mouse_button)
+    return log_browser.mouse_release(Log_browser_state, x,y, mouse_button, is_touch, presses)
   end
 end
 
@@ -341,7 +342,7 @@ function source.text_input(t)
   end
 end
 
-function source.keychord_press(chord, key)
+function source.keychord_press(chord, key, scancode, is_repeat)
   Cursor_time = 0  -- ensure cursor is visible immediately after it moves
 --?   print('source keychord')
   if Show_file_navigator then
@@ -381,9 +382,9 @@ function source.keychord_press(chord, key)
     return
   end
   if Focus == 'edit' then
-    return edit.keychord_press(Editor_state, chord, key)
+    return edit.keychord_press(Editor_state, chord, key, scancode, is_repeat)
   else
-    return log_browser.keychord_press(Log_browser_state, chord, key)
+    return log_browser.keychord_press(Log_browser_state, chord, key, scancode, is_repeat)
   end
 end
 
@@ -392,6 +393,6 @@ function source.key_release(key, scancode)
   if Focus == 'edit' then
     return edit.key_release(Editor_state, key, scancode)
   else
-    return log_browser.keychord_press(Log_browser_state, chordkey, scancode)
+    return log_browser.key_release(Log_browser_state, key, scancode)
   end
 end
diff --git a/source_edit.lua b/source_edit.lua
index 5351857..77487ea 100644
--- a/source_edit.lua
+++ b/source_edit.lua
@@ -53,8 +53,7 @@ function edit.initialize_state(top, left, right, font, font_height, line_height)
 
     -- rendering wrapped text lines needs some additional short-lived data per line:
     --   startpos, the index of data the line starts rendering from, can only be >1 for topmost line on screen
-    --   fragments: snippets of the line guaranteed to not straddle screen lines
-    --   screen_line_starting_pos: optional array of grapheme indices if it wraps over more than one screen line
+    --   screen_line_starting_pos: optional array of codepoint indices if it wraps over more than one screen line
     line_cache = {},
 
     -- Given wrapping, any potential location for the text cursor can be described in two ways:
@@ -143,14 +142,13 @@ function edit.cursor_on_text(State)
 end
 
 function edit.put_cursor_on_next_text_line(State)
-  while true do
-    if State.cursor1.line >= #State.lines then
-      break
-    end
-    if State.lines[State.cursor1.line].mode == 'text' then
-      break
-    end
-    State.cursor1.line = State.cursor1.line+1
+  local line = State.cursor1.line
+  if State.lines[line].mode == 'text' then return end
+  while line <= #State.lines and State.lines[line].mode ~= 'text' do
+    line = line+1
+  end
+  if line <= #State.lines and State.lines[line].mode == 'text' then
+    State.cursor1.line = line
     State.cursor1.pos = 1
   end
 end
@@ -190,8 +188,9 @@ function edit.draw(State, hide_cursor, show_line_numbers)
                        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)})
+                       Drawing.before = nil
+                       schedule_save(State)
                      end,
         })
       end
@@ -233,8 +232,7 @@ function edit.quit(State)
   end
 end
 
-function edit.mouse_press(State, x,y, mouse_button)
-  love.keyboard.setTextInput(true)  -- bring up keyboard on touch screen
+function edit.mouse_press(State, x,y, mouse_button, is_touch, presses)
   if State.search_term then return end
   State.mouse_down = mouse_button
 --?   print_and_log(('edit.mouse_press: cursor at %d,%d'):format(State.cursor1.line, State.cursor1.pos))
@@ -281,7 +279,7 @@ function edit.mouse_press(State, x,y, mouse_button)
         State.lines.current_drawing_index = line_index
         State.lines.current_drawing = line
         Drawing.before = snapshot(State, line_index)
-        Drawing.mouse_press(State, line_index, x,y, mouse_button)
+        Drawing.mouse_press(State, line_index, x,y, mouse_button, is_touch, presses)
         return
       end
     end
@@ -294,21 +292,21 @@ function edit.mouse_press(State, x,y, mouse_button)
   State.selection1 = Text.final_text_loc_on_screen(State)
 end
 
-function edit.mouse_release(State, x,y, mouse_button)
+function edit.mouse_release(State, x,y, mouse_button, is_touch, presses)
   if State.search_term then return end
 --?   print_and_log(('edit.mouse_release: cursor at %d,%d'):format(State.cursor1.line, State.cursor1.pos))
   State.mouse_down = nil
   if State.lines.current_drawing then
-    Drawing.mouse_release(State, x,y, mouse_button)
-    schedule_save(State)
+    Drawing.mouse_release(State, x,y, mouse_button, is_touch, presses)
     if Drawing.before then
       record_undo_event(State, {before=Drawing.before, after=snapshot(State, State.lines.current_drawing_index)})
       Drawing.before = nil
     end
+    schedule_save(State)
   else
 --?     print_and_log('edit.mouse_release: no current drawing')
     if y < State.top then
-      State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+      State.cursor1 = deepcopy(State.screen_top1)
       edit.clean_up_mouse_press(State)
       return
     end
@@ -351,7 +349,7 @@ end
 
 function edit.mouse_wheel_move(State, dx,dy)
   if dy > 0 then
-    State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+    State.cursor1 = deepcopy(State.screen_top1)
     edit.put_cursor_on_next_text_line(State)
     for i=1,math.floor(dy) do
       Text.up(State)
@@ -385,14 +383,14 @@ function edit.text_input(State, t)
   schedule_save(State)
 end
 
-function edit.keychord_press(State, chord, key)
+function edit.keychord_press(State, chord, key, scancode, is_repeat)
   if State.selection1.line and
       not State.lines.current_drawing and
       -- printable character created using shift key => delete selection
       -- (we're not creating any ctrl-shift- or alt-shift- combinations using regular/printable keys)
       (not App.shift_down() or utf8.len(key) == 1) and
       chord ~= 'C-a' and chord ~= 'C-c' and chord ~= 'C-x' and chord ~= 'backspace' and chord ~= 'delete' and chord ~= 'C-z' and chord ~= 'C-y' and not App.is_cursor_movement(key) then
-    Text.delete_selection(State, State.left, State.right)
+    Text.delete_selection_and_record_undo_event(State)
   end
   if State.search_term then
     if chord == 'escape' then
@@ -400,7 +398,7 @@ function edit.keychord_press(State, chord, key)
       State.cursor1 = State.search_backup.cursor
       State.screen_top1 = State.search_backup.screen_top
       State.search_backup = nil
-      Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
     elseif chord == 'return' then
       State.search_term = nil
       State.search_backup = nil
@@ -408,9 +406,14 @@ function edit.keychord_press(State, chord, key)
       local len = utf8.len(State.search_term)
       local byte_offset = Text.offset(State.search_term, len)
       State.search_term = string.sub(State.search_term, 1, byte_offset-1)
-    elseif chord == 'down' then
-      State.cursor1.pos = State.cursor1.pos+1
+      State.cursor = deepcopy(State.search_backup.cursor)
+      State.screen_top = deepcopy(State.search_backup.screen_top)
       Text.search_next(State)
+    elseif chord == 'down' then
+      if #State.search_term > 0 then
+        Text.right(State)
+        Text.search_next(State)
+      end
     elseif chord == 'up' then
       Text.search_previous(State)
     end
@@ -442,11 +445,9 @@ function edit.keychord_press(State, chord, key)
       State.cursor1 = deepcopy(src.cursor)
       State.selection1 = deepcopy(src.selection)
       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)
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
       schedule_save(State)
     end
   elseif chord == 'C-y' then
@@ -459,8 +460,7 @@ function edit.keychord_press(State, chord, key)
       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)
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
       schedule_save(State)
     end
   -- clipboard
@@ -473,7 +473,7 @@ function edit.keychord_press(State, chord, key)
       App.set_clipboard(s)
     end
   elseif chord == 'C-x' then
-    local s = Text.cut_selection(State, State.left, State.right)
+    local s = Text.cut_selection_and_record_undo_event(State)
     if s then
       App.set_clipboard(s)
     end
@@ -495,14 +495,14 @@ function edit.keychord_press(State, chord, key)
     if Text.cursor_out_of_screen(State) then
       Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
     end
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
+    schedule_save(State)
   -- dispatch to drawing or text
   elseif App.mouse_down(1) or chord:sub(1,2) == 'C-' then
     local drawing_index, drawing = Drawing.current_drawing(State)
     if drawing_index then
       local before = snapshot(State, drawing_index)
-      Drawing.keychord_press(State, chord)
+      Drawing.keychord_press(State, chord, key, scancode, is_repeat)
       record_undo_event(State, {before=before, after=snapshot(State, drawing_index)})
       schedule_save(State)
     end
@@ -535,7 +535,7 @@ function edit.keychord_press(State, chord, key)
     end
     schedule_save(State)
   else
-    Text.keychord_press(State, chord)
+    Text.keychord_press(State, chord, key, scancode, is_repeat)
   end
 end
 
diff --git a/source_select.lua b/source_select.lua
index b67dd16..a223b80 100644
--- a/source_select.lua
+++ b/source_select.lua
@@ -83,14 +83,14 @@ function Text.mouse_pos(State)
   return screen_bottom1.line, Text.pos_at_end_of_screen_line(State, screen_bottom1)
 end
 
-function Text.cut_selection(State)
+function Text.cut_selection_and_record_undo_event(State)
   if State.selection1.line == nil then return end
   local result = Text.selection(State)
-  Text.delete_selection(State)
+  Text.delete_selection_and_record_undo_event(State)
   return result
 end
 
-function Text.delete_selection(State)
+function Text.delete_selection_and_record_undo_event(State)
   if State.selection1.line == nil then return end
   local minl,maxl = minmax(State.selection1.line, State.cursor1.line)
   local before = snapshot(State, minl, maxl)
diff --git a/source_text.lua b/source_text.lua
index 6e0c4f9..c281acf 100644
--- a/source_text.lua
+++ b/source_text.lua
@@ -90,9 +90,9 @@ function Text.screen_line(line, line_cache, i)
   if i >= #line_cache.screen_line_starting_pos then
     return line.data:sub(offset)
   end
-  local endpos = line_cache.screen_line_starting_pos[i+1]-1
+  local endpos = line_cache.screen_line_starting_pos[i+1]
   local end_offset = Text.offset(line.data, endpos)
-  return line.data:sub(offset, end_offset)
+  return line.data:sub(offset, end_offset-1)
 end
 
 function Text.draw_cursor(State, x, y)
@@ -198,7 +198,7 @@ function Text.text_input(State, t)
   if App.mouse_down(1) then return end
   if App.any_modifier_down() then
     if App.key_down(t) then
-      -- The modifiers didn't change the key. Handle it in keychord_pressed.
+      -- The modifiers didn't change the key. Handle it in keychord_press.
       return
     else
       -- Key mutated by the keyboard layout. Continue below.
@@ -223,19 +223,18 @@ function Text.insert_at_cursor(State, t)
 end
 
 -- Don't handle any keys here that would trigger text_input above.
-function Text.keychord_press(State, chord)
+function Text.keychord_press(State, chord, key, scancode, is_repeat)
 --?   print('chord', chord, State.selection1.line, State.selection1.pos)
   --== shortcuts that mutate text
   if chord == 'return' then
     local before_line = State.cursor1.line
     local before = snapshot(State, before_line)
     Text.insert_return(State)
-    State.selection1 = {}
     if State.cursor_y > App.screen.height - State.line_height then
       Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
     end
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
+    schedule_save(State)
   elseif chord == 'tab' then
     local before = snapshot(State, State.cursor1.line)
 --?     print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
@@ -245,11 +244,11 @@ function Text.keychord_press(State, chord)
       Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
 --?       print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
     end
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+    schedule_save(State)
   elseif chord == 'backspace' then
     if State.selection1.line then
-      Text.delete_selection(State, State.left, State.right)
+      Text.delete_selection_and_record_undo_event(State)
       schedule_save(State)
       return
     end
@@ -289,15 +288,15 @@ function Text.keychord_press(State, chord)
         line=State.cursor1.line,
         pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
       }
-      Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
     end
     Text.clear_screen_line_cache(State, State.cursor1.line)
     assert(Text.le1(State.screen_top1, State.cursor1), ('screen_top (line=%d,pos=%d) is below cursor (line=%d,pos=%d)'):format(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos))
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+    schedule_save(State)
   elseif chord == 'delete' then
     if State.selection1.line then
-      Text.delete_selection(State, State.left, State.right)
+      Text.delete_selection_and_record_undo_event(State)
       schedule_save(State)
       return
     end
@@ -327,8 +326,8 @@ function Text.keychord_press(State, chord)
       table.remove(State.line_cache, State.cursor1.line+1)
     end
     Text.clear_screen_line_cache(State, State.cursor1.line)
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+    schedule_save(State)
   --== shortcuts that move the cursor
   elseif chord == 'left' then
     Text.left(State)
@@ -338,12 +337,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'S-left' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.left(State)
   elseif chord == 'S-right' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.right(State)
   -- C- hotkeys reserved for drawings, so we'll use M-
@@ -355,12 +354,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'M-S-left' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.word_left(State)
   elseif chord == 'M-S-right' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.word_right(State)
   elseif chord == 'home' then
@@ -371,12 +370,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'S-home' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.start_of_line(State)
   elseif chord == 'S-end' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.end_of_line(State)
   elseif chord == 'up' then
@@ -387,12 +386,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'S-up' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.up(State)
   elseif chord == 'S-down' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.down(State)
   elseif chord == 'pageup' then
@@ -403,12 +402,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'S-pageup' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.pageup(State)
   elseif chord == 'S-pagedown' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.pagedown(State)
   end
@@ -425,9 +424,9 @@ end
 
 function Text.pageup(State)
   State.screen_top1 = Text.previous_screen_top1(State)
-  State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+  State.cursor1 = deepcopy(State.screen_top1)
   Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
-  Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+  Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
 end
 
 -- return the top y coordinate of a given line_index,
@@ -474,9 +473,9 @@ end
 
 function Text.pagedown(State)
   State.screen_top1 = Text.screen_bottom1(State)
-  State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+  State.cursor1 = deepcopy(State.screen_top1)
   Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
-  Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+  Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
 end
 
 -- return the location of the start of the bottom-most line on screen
@@ -538,7 +537,7 @@ function Text.up(State)
       line=State.cursor1.line,
       pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
     }
-    Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+    Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
   end
 end
 
@@ -594,7 +593,7 @@ end
 function Text.start_of_line(State)
   State.cursor1.pos = 1
   if Text.lt1(State.cursor1, State.screen_top1) then
-    State.screen_top1 = {line=State.cursor1.line, pos=State.cursor1.pos}  -- copy
+    State.screen_top1 = deepcopy(State.cursor1)
   end
 end
 
@@ -684,7 +683,7 @@ function Text.left(State)
       line=State.cursor1.line,
       pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
     }
-    Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+    Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
   end
 end
 
@@ -829,7 +828,7 @@ function Text.snap_cursor_to_bottom_of_screen(State)
   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)
-  Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+  Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
 end
 
 function Text.in_line(State, line_index, x,y)
@@ -1103,7 +1102,7 @@ function Text.tweak_screen_top_and_cursor(State)
   -- make sure cursor is on screen
   local screen_bottom1 = Text.screen_bottom1(State)
   if Text.lt1(State.cursor1, State.screen_top1) then
-    State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+    State.cursor1 = deepcopy(State.screen_top1)
   elseif State.cursor1.line >= screen_bottom1.line then
     if Text.cursor_out_of_screen(State) then
       State.cursor1 = Text.final_text_loc_on_screen(State)
@@ -1118,7 +1117,7 @@ function Text.cursor_out_of_screen(State)
 end
 
 function Text.redraw_all(State)
---?   print('clearing fragments')
+--?   print('clearing line caches')
   -- Perform some early sanity checking here, in hopes that we correctly call
   -- this whenever we change editor state.
   if State.right <= State.left then
diff --git a/source_text_tests.lua b/source_text_tests.lua
index 11cc823..12fb9c6 100644
--- a/source_text_tests.lua
+++ b/source_text_tests.lua
@@ -319,7 +319,7 @@ function test_click_on_empty_line()
   check_nil(Editor_state.selection1.line, 'selection is empty to avoid perturbing future edits')
 end
 
-function test_click_below_all_lines()
+function test_click_below_final_line_of_file()
   -- display one line
   App.screen.init{width=50, height=80}
   Editor_state = edit.initialize_test_state()
@@ -331,8 +331,9 @@ function test_click_below_all_lines()
   -- click below first line
   edit.draw(Editor_state)
   edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+50, 1)
-  -- cursor doesn't move
-  check_eq(Editor_state.cursor1.line, 1, 'cursor')
+  -- cursor goes to bottom
+  check_eq(Editor_state.cursor1.line, 1, 'cursor:line')
+  check_eq(Editor_state.cursor1.pos, 4, 'cursor:pos')
   -- selection remains empty
   check_nil(Editor_state.selection1.line, 'selection is empty to avoid perturbing future edits')
 end
@@ -2073,3 +2074,31 @@ function test_search_wrap_upwards()
   check_eq(Editor_state.cursor1.line, 1, '1/cursor:line')
   check_eq(Editor_state.cursor1.pos, 6, '1/cursor:pos')
 end
+
+function test_search_downwards_from_end_of_line()
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=4}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  edit.draw(Editor_state)
+  -- search for empty string
+  edit.run_after_keychord(Editor_state, 'C-f', 'f')
+  edit.run_after_keychord(Editor_state, 'down', 'down')
+  -- no crash
+end
+
+function test_search_downwards_from_final_pos_of_line()
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=3}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  edit.draw(Editor_state)
+  -- search for empty string
+  edit.run_after_keychord(Editor_state, 'C-f', 'f')
+  edit.run_after_keychord(Editor_state, 'down', 'down')
+  -- no crash
+end
diff --git a/source_undo.lua b/source_undo.lua
index e5dea93..772e5da 100644
--- a/source_undo.lua
+++ b/source_undo.lua
@@ -57,16 +57,8 @@ function snapshot(State, s,e)
     end_line=e,
     -- no filename; undo history is cleared when filename changes
   }
-  -- deep copy lines without cached stuff like text fragments
   for i=s,e do
-    local line = State.lines[i]
-    if line.mode == 'text' then
-      table.insert(event.lines, {mode='text', data=line.data})  -- I've forgotten: should we deepcopy(line.data)?
-    elseif line.mode == 'drawing' then
-      table.insert(event.lines, {mode='drawing', h=line.h, points=deepcopy(line.points), shapes=deepcopy(line.shapes), pending={}})
-    else
-      assert(false, ('unknown line mode %s'):format(line.mode))
-    end
+    table.insert(event.lines, deepcopy(State.lines[i]))
   end
   return event
 end
@@ -89,26 +81,15 @@ function patch(lines, from, to)
   end
 end
 
-function patch_placeholders(line_cache, from, to)
-  assert(from.start_line == to.start_line, 'failed to patch undo operation')
-  for i=from.end_line,from.start_line,-1 do
-    table.remove(line_cache, i)
-  end
-  assert(#to.lines == to.end_line-to.start_line+1, 'failed to patch undo operation')
-  for i=1,#to.lines do
-    table.insert(line_cache, to.start_line+i-1, {})
-  end
-end
-
 -- https://stackoverflow.com/questions/640642/how-do-you-copy-a-lua-table-by-value/26367080#26367080
 function deepcopy(obj, seen)
   if type(obj) ~= 'table' then return obj end
-  if seen and seen[obj] then return seen[obj] end
-  local s = seen or {}
+  seen = seen or {}
+  if seen[obj] then return seen[obj] end
   local result = setmetatable({}, getmetatable(obj))
-  s[obj] = result
+  seen[obj] = result
   for k,v in pairs(obj) do
-    result[deepcopy(k, s)] = deepcopy(v, s)
+    result[deepcopy(k, seen)] = deepcopy(v, seen)
   end
   return result
 end
diff --git a/text.lua b/text.lua
index 5814693..9db8b16 100644
--- a/text.lua
+++ b/text.lua
@@ -47,7 +47,7 @@ function Text.draw(State, line_index, y, startpos)
           end
         end
       end
-      -- render fragment
+      -- render screen line
       App.color(Text_color)
       App.screen.print(screen_line, State.left,y)
       y = y + State.line_height
@@ -65,9 +65,9 @@ function Text.screen_line(line, line_cache, i)
   if i >= #line_cache.screen_line_starting_pos then
     return line.data:sub(offset)
   end
-  local endpos = line_cache.screen_line_starting_pos[i+1]-1
+  local endpos = line_cache.screen_line_starting_pos[i+1]
   local end_offset = Text.offset(line.data, endpos)
-  return line.data:sub(offset, end_offset)
+  return line.data:sub(offset, end_offset-1)
 end
 
 function Text.draw_cursor(State, x, y)
@@ -123,7 +123,7 @@ function Text.text_input(State, t)
   if App.mouse_down(1) then return end
   if App.any_modifier_down() then
     if App.key_down(t) then
-      -- The modifiers didn't change the key. Handle it in keychord_pressed.
+      -- The modifiers didn't change the key. Handle it in keychord_press.
       return
     else
       -- Key mutated by the keyboard layout. Continue below.
@@ -147,19 +147,18 @@ function Text.insert_at_cursor(State, t)
 end
 
 -- Don't handle any keys here that would trigger text_input above.
-function Text.keychord_press(State, chord)
+function Text.keychord_press(State, chord, key, scancode, is_repeat)
 --?   print('chord', chord, State.selection1.line, State.selection1.pos)
-  --== shortcuts that mutate text
+  --== shortcuts that mutate text (must schedule_save)
   if chord == 'return' then
     local before_line = State.cursor1.line
     local before = snapshot(State, before_line)
     Text.insert_return(State)
-    State.selection1 = {}
     if State.cursor_y > App.screen.height - State.line_height then
       Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
     end
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
+    schedule_save(State)
   elseif chord == 'tab' then
     local before = snapshot(State, State.cursor1.line)
 --?     print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
@@ -169,11 +168,11 @@ function Text.keychord_press(State, chord)
       Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
 --?       print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
     end
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+    schedule_save(State)
   elseif chord == 'backspace' then
     if State.selection1.line then
-      Text.delete_selection(State, State.left, State.right)
+      Text.delete_selection_and_record_undo_event(State)
       schedule_save(State)
       return
     end
@@ -208,15 +207,15 @@ function Text.keychord_press(State, chord)
         line=State.cursor1.line,
         pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
       }
-      Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+      Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
     end
     Text.clear_screen_line_cache(State, State.cursor1.line)
     assert(Text.le1(State.screen_top1, State.cursor1), ('screen_top (line=%d,pos=%d) is below cursor (line=%d,pos=%d)'):format(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos))
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+    schedule_save(State)
   elseif chord == 'delete' then
     if State.selection1.line then
-      Text.delete_selection(State, State.left, State.right)
+      Text.delete_selection_and_record_undo_event(State)
       schedule_save(State)
       return
     end
@@ -244,8 +243,8 @@ function Text.keychord_press(State, chord)
       table.remove(State.line_cache, State.cursor1.line+1)
     end
     Text.clear_screen_line_cache(State, State.cursor1.line)
-    schedule_save(State)
     record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
+    schedule_save(State)
   --== shortcuts that move the cursor
   elseif chord == 'left' then
     Text.left(State)
@@ -255,12 +254,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'S-left' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.left(State)
   elseif chord == 'S-right' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.right(State)
   -- C- hotkeys reserved for drawings, so we'll use M-
@@ -272,12 +271,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'M-S-left' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.word_left(State)
   elseif chord == 'M-S-right' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.word_right(State)
   elseif chord == 'home' then
@@ -288,12 +287,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'S-home' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.start_of_line(State)
   elseif chord == 'S-end' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.end_of_line(State)
   elseif chord == 'up' then
@@ -304,12 +303,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'S-up' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.up(State)
   elseif chord == 'S-down' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.down(State)
   elseif chord == 'pageup' then
@@ -320,12 +319,12 @@ function Text.keychord_press(State, chord)
     State.selection1 = {}
   elseif chord == 'S-pageup' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.pageup(State)
   elseif chord == 'S-pagedown' then
     if State.selection1.line == nil then
-      State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos}
+      State.selection1 = deepcopy(State.cursor1)
     end
     Text.pagedown(State)
   end
@@ -342,9 +341,9 @@ end
 
 function Text.pageup(State)
   State.screen_top1 = Text.previous_screen_top1(State)
-  State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+  State.cursor1 = deepcopy(State.screen_top1)
   Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
-  Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+  Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
 end
 
 -- return the top y coordinate of a given line_index,
@@ -380,9 +379,9 @@ end
 
 function Text.pagedown(State)
   State.screen_top1 = Text.screen_bottom1(State)
-  State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+  State.cursor1 = deepcopy(State.screen_top1)
   Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
-  Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+  Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
 end
 
 -- return the location of the start of the bottom-most line on screen
@@ -435,7 +434,7 @@ function Text.up(State)
       line=State.cursor1.line,
       pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
     }
-    Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+    Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
   end
 end
 
@@ -484,7 +483,7 @@ end
 function Text.start_of_line(State)
   State.cursor1.pos = 1
   if Text.lt1(State.cursor1, State.screen_top1) then
-    State.screen_top1 = {line=State.cursor1.line, pos=State.cursor1.pos}  -- copy
+    State.screen_top1 = deepcopy(State.cursor1)
   end
 end
 
@@ -564,7 +563,7 @@ function Text.left(State)
       line=State.cursor1.line,
       pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
     }
-    Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+    Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
   end
 end
 
@@ -611,7 +610,7 @@ function Text.pos_at_end_of_screen_line(State, loc1)
   assert(false, ('invalid pos %d'):format(loc1.pos))
 end
 
-function Text.final_text_loc_on_screen(State)
+function Text.final_loc_on_screen(State)
   local screen_bottom1 = Text.screen_bottom1(State)
   return {
     line=screen_bottom1.line,
@@ -658,7 +657,7 @@ function Text.snap_cursor_to_bottom_of_screen(State)
   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)
-  Text.redraw_all(State)  -- if we're scrolling, reclaim all fragments to avoid memory leaks
+  Text.redraw_all(State)  -- if we're scrolling, reclaim all line caches to avoid memory leaks
 end
 
 function Text.in_line(State, line_index, x,y)
@@ -924,10 +923,10 @@ function Text.tweak_screen_top_and_cursor(State)
   -- make sure cursor is on screen
   local screen_bottom1 = Text.screen_bottom1(State)
   if Text.lt1(State.cursor1, State.screen_top1) then
-    State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
+    State.cursor1 = deepcopy(State.screen_top1)
   elseif State.cursor1.line >= screen_bottom1.line then
     if Text.cursor_out_of_screen(State) then
-      State.cursor1 = Text.final_text_loc_on_screen(State)
+      State.cursor1 = Text.final_loc_on_screen(State)
     end
   end
 end
@@ -939,7 +938,7 @@ function Text.cursor_out_of_screen(State)
 end
 
 function Text.redraw_all(State)
---?   print('clearing fragments')
+--?   print('clearing line caches')
   -- Perform some early sanity checking here, in hopes that we correctly call
   -- this whenever we change editor state.
   if State.right <= State.left then
diff --git a/text_tests b/text_tests
index 2a31131..85d9de9 100644
--- a/text_tests
+++ b/text_tests
@@ -23,7 +23,7 @@ click on wrapping line rendered from partway at top of screen
 click past end of wrapping line
 click past end of wrapping line containing non ascii
 click past end of word wrapping line
-click below final line does nothing
+click below final line of file
 
 # cursor movement
 move left
diff --git a/text_tests.lua b/text_tests.lua
index 9b3b60c..962de7d 100644
--- a/text_tests.lua
+++ b/text_tests.lua
@@ -293,7 +293,7 @@ function test_click_on_empty_line()
   check_nil(Editor_state.selection1.line, 'selection is empty to avoid perturbing future edits')
 end
 
-function test_click_below_all_lines()
+function test_click_below_final_line_of_file()
   -- display one line
   App.screen.init{width=50, height=80}
   Editor_state = edit.initialize_test_state()
@@ -305,8 +305,9 @@ function test_click_below_all_lines()
   -- click below first line
   edit.draw(Editor_state)
   edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+50, 1)
-  -- cursor doesn't move
-  check_eq(Editor_state.cursor1.line, 1, 'cursor')
+  -- cursor goes to bottom
+  check_eq(Editor_state.cursor1.line, 1, 'cursor:line')
+  check_eq(Editor_state.cursor1.pos, 4, 'cursor:pos')
   -- selection remains empty
   check_nil(Editor_state.selection1.line, 'selection is empty to avoid perturbing future edits')
 end
@@ -1950,3 +1951,31 @@ function test_search_wrap_upwards()
   check_eq(Editor_state.cursor1.line, 1, '1/cursor:line')
   check_eq(Editor_state.cursor1.pos, 6, '1/cursor:pos')
 end
+
+function test_search_downwards_from_end_of_line()
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=4}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  edit.draw(Editor_state)
+  -- search for empty string
+  edit.run_after_keychord(Editor_state, 'C-f', 'f')
+  edit.run_after_keychord(Editor_state, 'down', 'down')
+  -- no crash
+end
+
+function test_search_downwards_from_final_pos_of_line()
+  App.screen.init{width=120, height=60}
+  Editor_state = edit.initialize_test_state()
+  Editor_state.lines = load_array{'abc', 'def', 'ghi'}
+  Text.redraw_all(Editor_state)
+  Editor_state.cursor1 = {line=1, pos=3}
+  Editor_state.screen_top1 = {line=1, pos=1}
+  edit.draw(Editor_state)
+  -- search for empty string
+  edit.run_after_keychord(Editor_state, 'C-f', 'f')
+  edit.run_after_keychord(Editor_state, 'down', 'down')
+  -- no crash
+end
diff --git a/undo.lua b/undo.lua
index a41ba38..69f7c31 100644
--- a/undo.lua
+++ b/undo.lua
@@ -1,8 +1,7 @@
 -- undo/redo by managing the sequence of events in the current session
 -- based on https://github.com/akkartik/mu1/blob/master/edit/012-editor-undo.mu
 
--- Incredibly inefficient; we make a copy of lines on every single keystroke.
--- The hope here is that we're either editing small files or just reading large files.
+-- makes a copy of lines on every single keystroke; will be inefficient with really long lines.
 -- TODO: highlight stuff inserted by any undo/redo operation
 -- TODO: coalesce multiple similar operations
 
@@ -55,10 +54,8 @@ function snapshot(State, s,e)
     end_line=e,
     -- no filename; undo history is cleared when filename changes
   }
-  -- deep copy lines without cached stuff like text fragments
   for i=s,e do
-    local line = State.lines[i]
-    table.insert(event.lines, {data=line.data})  -- I've forgotten: should we deepcopy(line.data)?
+    table.insert(event.lines, deepcopy(State.lines[i]))
   end
   return event
 end
@@ -81,26 +78,15 @@ function patch(lines, from, to)
   end
 end
 
-function patch_placeholders(line_cache, from, to)
-  assert(from.start_line == to.start_line, 'failed to patch undo operation')
-  for i=from.end_line,from.start_line,-1 do
-    table.remove(line_cache, i)
-  end
-  assert(#to.lines == to.end_line-to.start_line+1, 'failed to patch undo operation')
-  for i=1,#to.lines do
-    table.insert(line_cache, to.start_line+i-1, {})
-  end
-end
-
 -- https://stackoverflow.com/questions/640642/how-do-you-copy-a-lua-table-by-value/26367080#26367080
 function deepcopy(obj, seen)
   if type(obj) ~= 'table' then return obj end
-  if seen and seen[obj] then return seen[obj] end
-  local s = seen or {}
+  seen = seen or {}
+  if seen[obj] then return seen[obj] end
   local result = setmetatable({}, getmetatable(obj))
-  s[obj] = result
+  seen[obj] = result
   for k,v in pairs(obj) do
-    result[deepcopy(k, s)] = deepcopy(v, s)
+    result[deepcopy(k, seen)] = deepcopy(v, seen)
   end
   return result
 end