Fork of lines.love without drawings; useful starting point for further forks
Column_header_color = {r=0.7, g=0.7, b=0.7}
Pane_title_color = {r=0.5, g=0.5, b=0.5}
Pane_title_background_color = {r=0, g=0, b=0, a=0.1}
Pane_background_color = {r=0.7, g=0.7, b=0.7, a=0.1}
Grab_background_color = {r=0.7, g=0.7, b=0.7}
Cursor_pane_background_color = {r=0.7, g=0.7, b=0, a=0.1}
Menu_background_color = {r=0.6, g=0.8, b=0.6}
Menu_border_color = {r=0.6, g=0.7, b=0.6}
Menu_command_color = {r=0.2, g=0.2, b=0.2}
Command_palette_background_color = Menu_background_color
Command_palette_border_color = Menu_border_color
Command_palette_command_color = Menu_command_color
Command_palette_alternatives_background_color = Menu_background_color
Command_palette_highlighted_alternative_background_color = {r=0.5, g=0.7, b=0.3}
Command_palette_alternatives_color = {r=0.3, g=0.5, b=0.3}
Crosslink_color={r=0, g=0.7, b=0.7}
Crosslink_background_color={r=0, g=0, b=0, a=0.1}

run = {}

profile = require 'profile'

-- The note-taking app has a few differences with the baseline editor it's
-- forked from:
--  - most notes are read-only
--  - the editor operates entirely in viewport-relative coordinates; 0,0 is
--    the top-left corner of the window. However the note-taking app in
--    read-only mode largely operates in absolute coordinates; a potentially
--    large 2D space that the window is just a peephole into.
--
-- We'll use the rendering logic in the editor, but only use its event loop
-- when a window is being edited (there can only be one all over the entire
-- surface)
--
-- Most of the time the viewport affects each pane's top and screen_top. An
-- exception is when you're editing a pane and you scroll the cursor inside
-- it. In that case we want to affect the viewport (for all panes) based on
-- the editable pane's screen_top.

Editor_state = {}

-- called both in tests and real run
function run.initialize_globals()
  -- stuff we paginate over is organized as follows:
  --  - there are multiple columns
  --  - each column contains panes
  --  - each pane contains editor state as in lines.love
  Surface = {}

  -- The surface may show the same file in multiple panes. Share links between
  -- files (which will never go in Surface).
  Links = {}

  -- LÖVE renders N frames per second like any game engine, but we don't
  -- really need that. The only thing that animates in this app is the cursor.
  --
  -- Until I fix that, the architecture of this app will be to plan what to
  -- draw only when something changes. That way we minimize the amount of
  -- computation/power wasted on each of those frames.
  Panes_to_draw = {}  -- array of panes from surface
  Column_headers_to_draw = {}  -- strings with x coordinates

  Display_settings = {
    state={id='normal'},
      -- valid states:
      --   normal (show full surface)
      --   maximize (show just a single note; focus mode)
      --   search (notes currently on surface)
      --   search_all (notes in directory)
      --   searching_all (search in progress)
    x=0, y=0,  -- <==== Top-left corner of the viewport into the surface
    column_width=400,
    show_debug=false,
    palette=nil,  -- {command='', alternative_index=1, candidates=nil}
  }
  -- display settings that are constants
  Font_height = 20
  Line_height = math.floor(Font_height*1.3)

  -- space saved for headers
  -- this is only on the screen, not used on the surface itself
  Menu_status_bar_height = 5 + Line_height + 5
--?   print('menu height', Menu_status_bar_height)
  Column_header_height = 5 + Line_height + 5
--?   print('column header height', Column_header_height)
  Header_height = Menu_status_bar_height + Column_header_height

  -- padding is the space between panes on the surface
  Padding_vertical = 20  -- space between panes
  Padding_horizontal = 20

  -- margins are extra space inside the borders of panes on the surface
  Margin_above = 10
  Margin_below = 10

  Pan_step = 10
  Pan = {}

  Cursor_pane = {col=0, row=1}  -- surface column and row index, along with some cached data
  -- occasional secondary cursor
  Grab_pane = nil

  -- where we store our notes (pane id is also a relative path under there)
  Directory = nil
  Directory_error = nil  -- Any error encountered while determining Directory. If this is ever set, the app will do nothing but display it. (We can't rely on 'error' for that because we don't want to open up the sources in that situation.)

  -- blinking cursor
  Cursor_time = 0

  -- a read-only buffer for errors
  Error_log = edit.initialize_state(0, 0, Display_settings.column_width, Font_height, Line_height)
  Error_log.id = 'errors'
  Error_log.filename = nil  -- Will live within Directory. Don't forget to set this once Directory is initialized.
  Error_log.editable = false
  Text.redraw_all(Error_log)
  Current_error = nil
  Current_error_time = nil
end

-- called only for real run
function run.initialize(arg)
  log_new('run')
  if Settings then
    print('loading settings')
    run.load_settings()
    Directory = Settings.data_directory
  else
    print('no saved settings; initializing to defaults')
    run.initialize_default_settings()
  end

  if #arg > 0 then
    if not is_absolute_path(arg[1]) then
      Directory_error = 'Please use an unambiguous absolute path for the notes directory.'
      return
    end
    Directory = arg[1]
    print('setting Directory from commandline: '..arg[1])
    if Directory:sub(#Directory,#Directory) ~= '/' then
      Directory = Directory..'/'
    end
    print('Directory is now '..Directory)
  end

  if Directory == nil then
    print('setting Directory_error')
    if Settings == nil then
      Directory_error =
        "Please run pensieve.love once from a terminal window and specify the\n"..
        "location of your notes. The location will be remembered in future.\n"..
        "Thank you! If all goes well, you won't see this message ever again."
    else
      Directory_error =
        "Please perform a one-time migration for your notes:\n"..
        "\n"..
        "* (optional) Move any existing notes in "..App.save_dir.."data\n"..
        "   or similar locations to your preferred location.\n"..
        "\n"..
        "* (optional) Move any existing "..App.save_dir.."config\n"..
        "   file _inside_ your notes directory to preserve any prior state\n"..
        "   of your note-taking surface (open columns, etc.).\n"..
        "\n"..
        "* Please run pensieve.love once from a terminal window and specify the\n"..
        "   location of your notes as an absolute path. This location will be\n"..
        "   remembered in future.\n"..
        "\n"..
        "Thank you! If all goes well, you won't see this message ever again."
    end
    return
  end

  assert(Directory, 'no directory to browse notes in')
  print('Directory initialized to '..Directory)
  Error_log.filename = Directory..'errors'

  run.load_more_settings_from_notes_directory()

  Editor_state = nil  -- not used outside editor tests



  -- keep a few blank lines around: https://merveilles.town/@akkartik/110084833821965708
  love.window.setTitle('pensieve.love')



  if #arg > 1 then
    print('ignoring commandline args after '..arg[1])
  end

  print_and_log('reading notes from '..Directory)
  print_and_log('put any notes there (and make frequent backups)')

  if Display_settings.column_width > App.screen.width - Padding_horizontal - Margin_left - Margin_right - Padding_horizontal then
    Display_settings.column_width = math.max(200, App.screen.width - Padding_horizontal - Margin_left - Margin_right - Padding_horizontal)
  end

  Cursor_pane.col = math.min(Cursor_pane.col, #Surface)
  if Cursor_pane.col >= 1 then
    Cursor_pane.row = math.min(Cursor_pane.row, #Surface[Cursor_pane.col])
  end

  plan_draw()

  if rawget(_G, 'jit') then
    jit.off()
    jit.flush()
  end
end

function print_and_log(s)
  print(s)
  log(3, s)
end

function run.load_settings()
  Display_settings.font = love.graphics.newFont(Settings.font_height)
  -- set up desired window dimensions and make window resizable
  _, _, App.screen.flags = App.screen.size()
  App.screen.flags.resizable = true
  App.screen.width, App.screen.height = Settings.width, Settings.height
  App.screen.resize(App.screen.width, App.screen.height, App.screen.flags)
  run.set_window_position_from_settings(Settings)
  Font_height = Settings.font_height
  Line_height = math.floor(Font_height*1.3)
  love.graphics.setFont(Display_settings.font)
end

function run.load_more_settings_from_notes_directory()
  local f = App.open_for_reading(Directory..'config')
  if f then
    local directory_settings = json.decode(f:read())
    f:close()
    Display_settings.column_width = directory_settings.column_width
    for _,column_name in ipairs(directory_settings.columns) do
      create_column(column_name)
    end
    Cursor_pane.col = directory_settings.cursor_col
    Cursor_pane.row = directory_settings.cursor_row
    Display_settings.x = directory_settings.surface_x
    Display_settings.y = directory_settings.surface_y
  else
    -- initialize surface with a single column
    command.recently_modified()
  end
end

function run.set_window_position_from_settings(settings)
  local os = love.system.getOS()
  if os == 'Linux' then
    -- love.window.setPosition doesn't quite seem to do what is asked of it on Linux.
    App.screen.move(settings.x, settings.y-37, settings.displayindex)
  else
    App.screen.move(settings.x, settings.y, settings.displayindex)
  end
end

function run.initialize_default_settings()
  Display_settings.font = love.graphics.newFont(Font_height)
  love.graphics.setFont(Display_settings.font)
  run.initialize_window_geometry()
  Display_settings.column_width = 40*Display_settings.font:getWidth('m')
end

function run.initialize_window_geometry()
  -- Initialize window width/height and make window resizable.
  --
  -- I get tempted to have opinions about window dimensions here, but they're
  -- non-portable:
  --  - maximizing doesn't work on mobile and messes things up
  --  - maximizing keeps the title bar on screen in Linux, but off screen on
  --    Windows. And there's no way to get the height of the title bar.
  -- It seems more robust to just follow LÖVE's default window size until
  -- someone overrides it.
  App.screen.width, App.screen.height, App.screen.flags = App.screen.size()
  App.screen.flags.resizable = true
  App.screen.resize(App.screen.width, App.screen.height, App.screen.flags)
end

function run.resize(w, h)
  App.screen.width, App.screen.height = w, h
  if Directory_error then return end
--?   print('resize:', App.screen.width, App.screen.height)
  plan_draw()
end

function load_pane(id)
--?   print('load pane from file', id)
  local result = edit.initialize_state(0, 0, math.min(Display_settings.column_width, App.screen.width-Margin_right), love.graphics.getFont(), Font_height, Line_height)
  result.id = id
  result.filename = Directory..id
  load_from_disk(result)
  -- links are shared across all instances of an id, so never clobber them once loaded
  if Links[id] == nil then
    Links[id] = load_links(id)
  end
  result.font_height = Font_height
  result.line_height = Line_height
  result.editable = false
  edit.check_locs(result)
  Text.redraw_all(result)
  return result
end

function height(pane)
  if pane._height == nil then
    refresh_pane_height(pane)
  end
  return pane._height
end

-- keep the structure of this function sync'd with plan_draw
function refresh_pane_height(pane)
--?   print('refresh pane height')
  local y = 0
  if pane.title then
    y = y + 5+Line_height+5
  end
  rehydrate_pane(pane)
  for i=1,#pane.lines do
    local line = pane.lines[i]
    if line.mode == 'text' then
      Text.populate_screen_line_starting_pos(pane, i)
      y = y + Line_height*#pane.line_cache[i].screen_line_starting_pos
      Text.clear_screen_line_cache(pane, i)
    elseif line.mode == 'drawing' then
      -- nothing
      y = y + Drawing.pixels(line.h, Display_settings.column_width) + Drawing_padding_height
    else
      assert(false, ('unknown line mode %s'):format(line.mode))
    end
  end
  if Links[pane.id] and not empty(Links[pane.id]) then
    y = y + 5+Line_height+5  -- for crosslinks
  end
  pane._height = y
end

-- titles are optional and so affect the height of the pane
function add_title(pane, title)
  pane.title = title
  pane._height = nil
end

-- keep the structure of this function sync'd with refresh_pane_height
function plan_draw()
--?   print_and_log(('plan_draw %d,%d'):format(Display_settings.x, Display_settings.y))
  Panes_to_draw = {}
  Column_headers_to_draw = {}
  local sx = Padding_horizontal + Margin_left
  if Grab_pane then rehydrate_pane(Grab_pane) end
  for column_index, column in ipairs(Surface) do
    if should_show_column(sx) then
      table.insert(Column_headers_to_draw, {name=('%d. %s'):format(column_index, column.name), x = sx-Display_settings.x})
      local sy = Padding_vertical
      for pane_index, pane in ipairs(column) do
        if sy > Display_settings.y + App.screen.height - Header_height then
          break
        end
--?         print_and_log(('plan_draw bounds %d,%d %s %d,%d'):format(column_index, pane_index, pane.id, sx,sy))
        if should_show_pane(pane, sy) then
          table.insert(Panes_to_draw, pane)
          -- stash some short-lived variables
          pane.column_index = column_index
          pane.pane_index = pane_index
          local y_offset = 0
          local body_sy = sy
          if column[pane_index].title then
            body_sy = body_sy + 5+Line_height+5
          end
          if body_sy < Display_settings.y then
            pane.screen_top1, y_offset = schema1_of_y(pane, Display_settings.y - body_sy)
            pane.top = 0
          else
            pane.screen_top1 = {line=1, pos=1}
            pane.top = body_sy - Display_settings.y
          end
          pane.top = Header_height + Margin_above + pane.top - y_offset
--?           print('bounds: =>', pane.top)
          pane.left = sx - Display_settings.x
          pane.right = pane.left + Display_settings.column_width
          pane.width = pane.right - pane.left
        else
          -- clear bounds to catch issues early
          pane.top = nil
--?           print('bounds: =>', pane.top)
        end
        sy = sy + Margin_above + height(pane) + Margin_below + Padding_vertical
      end
    else
      -- clear bounds to catch issues early
      for _, pane in ipairs(column) do
        pane.top = nil
      end
    end
    sx = sx + Margin_right + Display_settings.column_width + Padding_horizontal + Margin_left
  end
end

function run.draw()
  if Directory_error then
    love.graphics.print(Directory_error, 50,50)
    return
  end
--?   print('draw', Display_settings.y)
  if Display_settings.state.id == 'normal' then
    draw_normal_mode()
  elseif Display_settings.state.id == 'search' then
    draw_normal_mode()
    -- hack: pass in an unexpected object and pun some attributes
    Text.draw_search_bar(Display_settings.state, --[[force show cursor]] true)
  elseif Display_settings.state.id == 'search_all' then
    draw_normal_mode()
    -- only difference is in command palette below
  elseif Display_settings.state.id == 'searching_all' then
    draw_normal_mode()
    -- only difference is in command palette below
  elseif Display_settings.state.id == 'maximize' then
    if Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        pane.top = Header_height + Margin_above
        pane.left = App.screen.width/2 - 20*pane.font:getWidth('m')
        pane.right = App.screen.width/2 + 20*pane.font:getWidth('m')
        pane.width = pane.right - pane.left
        edit.draw(pane)
      end
    end
  else
    assert(false, ('pensieve is in an unknown state %s'):format(Display_settings.state.id))
  end
  if Grab_pane then
    local old_top, old_left, old_right = Grab_pane.top, Grab_pane.left, Grab_pane.right
    local old_screen_top = Grab_pane.screen_top1
    Grab_pane.screen_top1 = {line=1, pos=1}
    Grab_pane.top = App.screen.height - 10*Line_height
    Grab_pane.left = App.screen.width - Display_settings.column_width - Margin_right - Padding_horizontal
    Grab_pane.right = Grab_pane.left + Display_settings.column_width
    Grab_pane.width = Grab_pane.right - Grab_pane.left
    App.color(Grab_background_color)
    love.graphics.rectangle('fill', Grab_pane.left-Margin_left,Grab_pane.top-Margin_above, Grab_pane.width+Margin_left+Margin_right, App.screen.height-Grab_pane.top+Margin_above)
    edit.draw(Grab_pane)
    Grab_pane.top, Grab_pane.left, Grab_pane.right = old_top, old_left, old_right
    Grab_pane.screen_top1 = old_screen_top
  end
  draw_menu_bar()
  if Display_settings.state.id == 'search_all' or Display_settings.state.id == 'searching_all' then
    draw_command_palette_for_search_all()
  else
    if Display_settings.palette then
      draw_command_palette()
    else
      show_error()
    end
  end
  draw_debug()
end

function draw_normal_mode()
  assert(Cursor_pane.col, 'no current column')
  assert(Cursor_pane.row, 'no current row')
--?   print('draw', Display_settings.x, Display_settings.y)
  for _,pane in ipairs(Panes_to_draw) do
    assert(pane.top, "pane has no top coordinate; there's likely a problem in plan_draw")
--?     if pane.column_index == 1 and pane.pane_index == 1 then
--?       print('draw', pane.id, 'from y', pane.top, 'down to screen height', App.screen.height)
--?       print('screen top', pane.screen_top1.line, pane.screen_top1.pos)
--?       print('cursor', pane.cursor1.line, pane.cursor1.pos)
--?     end
    if pane.title and eq(pane.screen_top1, {line=1, pos=1}) then
      draw_title(pane)
    end
    edit.draw(pane)
    if pane_drew_to_bottom(pane) then
      draw_links(pane)
    end
    if pane.column_index == Cursor_pane.col and pane.pane_index == Cursor_pane.row then
      App.color(Cursor_pane_background_color)
      if pane.editable and Surface.cursor_on_screen_check then
        assert(pane.cursor_y, 'cursor went off screen; this should never happen')
        Surface.cursor_on_screen_check = false
      end
    else
      App.color(Pane_background_color)
    end
    love.graphics.rectangle('fill', pane.left-Margin_left,pane.top-Margin_above, pane.width+Margin_left+Margin_right, pane.bottom-pane.top+Margin_above+Margin_below)
  end
  for _,header in ipairs(Column_headers_to_draw) do
    -- column header
    App.color(Column_header_color)
    love.graphics.rectangle('fill', header.x - Margin_left, Menu_status_bar_height, Margin_left + Display_settings.column_width + Margin_right, Column_header_height)
    App.color(Text_color)
    love.graphics.print(header.name, header.x, Menu_status_bar_height+5)
  end
end

function pane_drew_to_bottom(pane)
  return pane.bottom < App.screen.height - Line_height
end

function should_show_column(sx)
  return overlap(sx-Margin_left, sx+Display_settings.column_width+Margin_right, Display_settings.x, Display_settings.x + App.screen.width)
end

function should_show_pane(pane, sy)
  return overlap(sy, sy + Margin_above + height(pane) + Margin_below, Display_settings.y, Display_settings.y + App.screen.height - Header_height)
end

function draw_title(pane)
  assert(pane.title, 'pane has no title')
  App.color(Pane_title_color)
  App.screen.print(pane.title, pane.left, pane.top-Margin_above -5-Line_height)
  App.color(Pane_title_background_color)
  love.graphics.rectangle('fill', pane.left-Margin_left, pane.top-Margin_above-5-Line_height-5, Margin_left+Display_settings.column_width+Margin_right, 5+Line_height+5)
end

function draw_links(pane)
  local links = Links[pane.id]
  if links == nil then return end
  if empty(links) then return end
  local x = pane.left
  for _,label in ipairs(Edge_list) do
    if links[label] then
      draw_link(pane.font, label, x, pane.bottom,
        array.find(Non_unique_links, label) and #links[label])
    end
    x = x + pane.font:getWidth(label) + 10 + 10
  end
  -- links we don't know about, just in case
  for link,_ in pairs(links) do
    if not Opposite[link] then
      draw_link(pane.font, link, x, pane.bottom)
      x = x + pane.font:getWidth(link) + 10 + 10
    end
  end
  pane.bottom = pane.bottom + 5+Line_height+5
end

function draw_link(font, label, x,y, n)
  if n and n > 1 then
    local s
    if n > 9 then
      s = '*'
    else
      s = tostring(n)
    end
    label = ('%s (%s)'):format(label, s)
  end
  local padding = 10
  local w = font:getWidth(label)+padding
  App.color(Crosslink_color)
  love.graphics.print(label, x, y+padding/2)
  App.color(Crosslink_background_color)
  love.graphics.rectangle('fill', x-padding/2, y+3, w, 2+Line_height+2)
end

-- assumes intervals are half-open: [lo, hi)
-- https://en.wikipedia.org/wiki/Interval_(mathematics)
function overlap(lo1,hi1, lo2,hi2)
  --     lo2       hi2
  --      |         |
  --    |     |
  --    |             |
  if lo1 <= lo2 and hi1 > lo2 then
    return true
  end
  --     lo2       hi2
  --      |         |
  --          |       |
  if lo1 < hi2 and hi1 >= hi2 then
    return true
  end
  --     lo2       hi2
  --      |         |
  --          |   |
  return lo1 >= lo2 and hi1 <= hi2
end

function run.update(dt)
  if Directory_error then return end
  update_footprint()
  Cursor_time = Cursor_time + dt
  if App.mouse_y() < Header_height then
    -- column header
    love.mouse.setCursor(love.mouse.getSystemCursor('arrow'))
  elseif in_pane(App.mouse_x(), App.mouse_y()) then
    love.mouse.setCursor(love.mouse.getSystemCursor('arrow'))
  else
    love.mouse.setCursor(love.mouse.getSystemCursor('hand'))
  end
  if Pan.x then
    Display_settings.x = math.max(Pan.x-App.mouse_x(), 0)
    Display_settings.y = math.max(Pan.y-(App.mouse_y()-Header_height), 0)
  end
  if Cursor_pane.col >= 1 then
    local pane = Surface[Cursor_pane.col][Cursor_pane.row]
    if pane and pane.editable then
      edit.update(pane, dt)
    end
  end
  if not Display_settings.palette and (Display_settings.state.id == 'normal' or Display_settings.state.id == 'search') and App.mouse_down(1) then
    -- pan the surface by dragging
    plan_draw()
  end
  if Display_settings.state.id == 'searching_all' then
    resume_search_all()
  end
  if Current_error then
    if App.get_time() - Current_error_time > 3 then
      Current_error = nil
      Current_error_time = nil
    end
  end
end

function in_pane(x,y)
  -- duplicate some logic from App.draw
  local sx,sy = to_surface(x,y)
  local x = Padding_horizontal
  for column_idx, column in ipairs(Surface) do
    if sx < x then
      return false
    end
    if sx < x + Margin_left + Display_settings.column_width + Margin_right then
      local y = Padding_vertical
      for pane_idx, pane in ipairs(column) do
        if sy < y then
          return false
        end
        if sy < y + Margin_above + height(pane) + Margin_below then
          return true
        end
        y = y + Margin_above + height(pane) + Margin_below + Padding_vertical
      end
    end
    x = x + Margin_left + Display_settings.column_width + Margin_right + Padding_horizontal
  end
  return false
end

function to_pane(sx,sy)
  -- duplicate some logic from App.draw
  local x = Padding_horizontal
  for column_idx, column in ipairs(Surface) do
    if sx < x then
      return nil
    end
    if sx < x + Margin_left + Display_settings.column_width + Margin_right then
      local y = Padding_vertical
      for pane_idx, pane in ipairs(column) do
        if sy < y then
          return nil
        end
        if sy < y + Margin_above + height(pane) + Margin_below then
          return {col=column_idx, row=pane_idx}
        end
        y = y + Margin_above + height(pane) + Margin_below + Padding_vertical
      end
    end
    x = x + Margin_left + Display_settings.column_width + Margin_right + Padding_horizontal
  end
  return nil
end

function to_surface(x, y)
  return x+Display_settings.x, y+Display_settings.y-Header_height
end

function run.quit()
  if Directory_error then return end
  if Cursor_pane.col >= 1 then
    local pane = Surface[Cursor_pane.col][Cursor_pane.row]
    if pane and pane.editable then
      stop_editing(pane)
    end
  end
end

function run.settings()
  -- avoid an infinite loop if handle_error ever tries to save settings
  assert(Directory_error == nil,
        "tried to save settings when we couldn't determine the directory to browse notes in")
  -- side effect: save notes-related settings inside Directory
  local column_names = {}
  for _,column in ipairs(Surface) do
    table.insert(column_names, column.name)
  end
  local status = App.write_file(Directory..'config',
      json.encode({
        columns=column_names,
        column_width=Display_settings.column_width,
        cursor_col=Cursor_pane.col,
        cursor_row=Cursor_pane.row,
        surface_x=Display_settings.x,
        surface_y=Display_settings.y,
      }))
  assert(status, 'failed to write settings')

  if Settings == nil then Settings = {} end
  Settings.x, Settings.y, Settings.displayindex = App.screen.position()
  return {
    x=Settings.x, y=Settings.y, displayindex=Settings.displayindex,
    width=App.screen.width, height=App.screen.height,
    font_height=Font_height,
    data_directory=Directory,
  }
end

function run.mouse_press(x,y, mouse_button)
  if Directory_error then return end
--?   print('app mouse pressed', x,y)
  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
  love.keyboard.setTextInput(true)  -- bring up keyboard on touch screen
  clear_selections()
  if Display_settings.state.id == 'normal' or Display_settings.state.id == 'search' or Display_settings.state.id == 'search_all' or Display_settings.state.id == 'searching_all' then
    mouse_press_in_normal_mode(x,y, mouse_button)
  elseif Display_settings.state.id == 'maximize' then
    if Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        edit.mouse_press(pane, x,y, mouse_button)
      end
    end
  else
    assert(false, ('pensieve is in an unknown state %s'):format(Display_settings.state.id))
  end
end

function clear_selections()
  for _,column in ipairs(Surface) do
    for _,pane in ipairs(column) do
      pane.selection1 = {}
    end
  end
end

function mouse_press_in_normal_mode(x,y, mouse_button)
  Pan = {}
  if y < Header_height then
    -- column headers currently not interactable
    return
  end
  local sx,sy = to_surface(x,y)
  if in_pane(x,y) then
    Cursor_pane = to_pane(sx,sy)
    if Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        edit.mouse_press(pane, x,y, mouse_button)
        pane._height = nil
      end
    end
  else
    Pan = {x=sx, y=sy}
  end
end

function run.mouse_release(x,y, mouse_button)
  if Directory_error then return end
--?   print('app mouse released')
  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
  if in_pane(x,y) or Display_settings.state.id == 'maximize' then
    if Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        edit.mouse_release(pane, x,y, mouse_button)
      end
    end
  end
  Pan = {}
end

function run.mouse_wheel_move(dx,dy)
  if Directory_error then return end
  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
  if Cursor_pane.col >= 1 then
    local pane = Surface[Cursor_pane.col][Cursor_pane.row]
    if pane then
      if pane.editable then
        if pane.cursor_x then
          edit.mouse_wheel_move(pane, dx,dy)
          plan_draw()
          return
        end
      end
    end
  end
  -- shift+scroll wheel doesn't set dx for me; manually do so if necessary
  if App.shift_down() then
    if dx == 0 then
      dx,dy = dy,dx
    end
  end
  if dy > 0 then
    for i=1,math.floor(dy) do
      pan_up()
    end
    plan_draw()
  elseif dy < 0 then
    for i=1,math.floor(-dy) do
      pan_down()
    end
    plan_draw()
  end
  if dx > 0 then
    for i=1,math.floor(dx) do
      pan_left()
    end
    plan_draw()
  elseif dx < 0 then
    for i=1,math.floor(-dx) do
      pan_right()
    end
    plan_draw()
  end
end

function run.text_input(t)
  if Directory_error then return end
  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
--?   print('textinput', t)
  -- hotkeys operating on the cursor pane
  if Display_settings.palette then
    Display_settings.palette.command = Display_settings.palette.command..t
    Display_settings.palette.alternative_index = 1
    Display_settings.palette.candidates = candidates()
  elseif Display_settings.state.id == 'normal' then
    if Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        if not pane.editable then
          -- global hotkeys for normal mode
          if t == 'X' then
            command.wider_columns()
            return
          elseif t == 'x' then
            command.narrower_columns()
            return
          end
          -- send keys to the current pane
        else
          if pane.cursor_x and pane.cursor_x >= 0 and pane.cursor_x < App.screen.width then
            if pane.cursor_y and pane.cursor_y >= Header_height and pane.cursor_y < App.screen.height then
--?               print(('%s typed in editor pane'):format(t))
              edit.text_input(pane, t)
              bring_cursor_of_cursor_pane_in_view('down')
              pane._height = nil
              plan_draw()
            end
          end
        end
      end
    end
  elseif Display_settings.state.id == 'search' then
--?     print('insert', t)
    Display_settings.state.search_term = Display_settings.state.search_term..t
    -- reset search state
    clear_selections()
    Display_settings.x = Display_settings.state.search_backup_x
    Display_settings.y = Display_settings.state.search_backup_y
    Cursor_pane = deepcopy(Display_settings.state.search_backup_cursor_pane)
    -- search again
    search_next()
    bring_cursor_of_cursor_pane_in_view('down')
    Surface.cursor_on_screen_check = true
    plan_draw()
  elseif Display_settings.state.id == 'search_all' then
    Display_settings.state.search_all_query = Display_settings.state.search_all_query..t
  elseif Display_settings.state.id == 'searching_all' then
    -- do nothing; keychord_press will interrupt it
  elseif Display_settings.state.id == 'maximize' then
    if Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        if pane.editable then
          edit.text_input(pane, t)
        end
      end
    end
  else
    assert(false, ('pensieve is in an unknown state %s'):format(Display_settings.state.id))
  end
end

function run.keychord_press(chord, key)
  if Directory_error then return end
  if App.run_tests == nil then
    log(2, os.date('%Y/%m/%d/%H-%M-%S')..' app.keychord_press '..chord..' '..key)
  end
  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
  -- global hotkeys
  if chord == 'C-=' then
    update_font_settings(Font_height+2)
  elseif chord == 'C--' then
    update_font_settings(Font_height-2)
  elseif chord == 'C-0' then
    update_font_settings(20)
  -- mode-specific hotkeys
  elseif Display_settings.palette then
    keychord_press_on_command_palette(chord, key)
  elseif Display_settings.state.id == 'normal' then
    if chord == 'C-return' then
      Display_settings.palette = {command='', alternative_index=1, candidates = initial_candidates()}
    elseif chord == 'C-f' then
      command.commence_find_on_surface()
    elseif Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        if pane.editable then
          if chord == 'C-e' then
--?             profile.stop()
--?             print(profile.report())
            command.exit_editing()
          elseif pane.cursor_x == nil then
            -- ignore if cursor is not visible on screen
            assert(pane.cursor_y == nil, 'cursor x is not set but y is set')
            panning_keychord_press(chord, key)
            plan_draw()
          else
            local old_top = {line=pane.screen_top1.line, pos=pane.screen_top1.pos}
            edit.keychord_press(pane, chord, key)
--?             print_and_log(('run.keychord_press A %s %d,%d'):format(chord, Display_settings.x, Display_settings.y))
            if chord == 'pagedown' or chord == 'S-pagedown' then
              pan_surface_to_screen_top_of_cursor_pane()
            elseif chord == 'backspace' or chord == 'up' or chord == 'left' or chord == 'pageup' then
              bring_cursor_of_cursor_pane_in_view('up')
            else
              bring_cursor_of_cursor_pane_in_view('down')
            end
            pane._height = nil
--?             print_and_log(('run.keychord_press Z %s %d,%d'):format(chord, Display_settings.x, Display_settings.y))
            plan_draw()
          end
        else
          keychord_press_in_normal_mode_with_immutable_pane(pane, chord, key)
          plan_draw()
        end
      end
    end
  elseif Display_settings.state.id == 'search' then
    keychord_press_in_search_mode(chord, key)
  elseif Display_settings.state.id == 'search_all' then
    keychord_press_in_search_all_mode(chord, key)
  elseif Display_settings.state.id == 'searching_all' then
    interrupt_search_all()
  elseif Display_settings.state.id == 'maximize' then
    if chord == 'C-return' then
      Display_settings.palette = {command='', alternative_index=1, candidates = initial_candidates()}
    else
      keychord_press_in_maximize_mode(chord, key)
    end
  else
    assert(false, ('pensieve is in an unknown state %s'):format(Display_settings.state.id))
  end
end

function update_font_settings(font_height)
  local column_width_in_ems = Display_settings.column_width / Display_settings.font:getWidth('m')
  Font_height = font_height
  Display_settings.font = love.graphics.newFont(Font_height)
  Line_height = math.floor(font_height*1.3)
  Menu_status_bar_height = 5 + Line_height + 5
  Column_header_height = 5 + Line_height + 5
  Header_height = Menu_status_bar_height + Column_header_height
  Display_settings.column_width = column_width_in_ems*Display_settings.font:getWidth('m')
  for _,column in ipairs(Surface) do
    for _,pane in ipairs(column) do
      pane.font = Display_settings.font
      pane.font_height = Font_height
      pane.line_height = Line_height
      pane.left = 0
      pane.right = Display_settings.column_width
      pane.width = pane.right - pane.left
    end
  end
  clear_all_pane_heights()
  plan_draw()
end

-- Scan all panes, while delegating as much work as possible to lines.love search.
-- * Text.search_next in lines.love scans from cursor while wrapping around
--   within the pane, so we need to work around that.
-- * Each pane's search_term field influences whether the search term at
--   cursor is highlighted, so we need to manage that as well. At any moment
--   we want the search_term to be set for at most a single pane.
--
-- Side-effect: we perturb the cursor of panes as we scan them.
function search_next()
  if #Display_settings.state.search_term == 0 then return end
  if Cursor_pane.col < 1 then return end
  clear_all_search_terms()
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    return
  end
--?   print('search next', Cursor_pane.col, Cursor_pane.row, pane.cursor1.line, pane.cursor1.pos)
  local old_cursor_in_cursor_pane = {line=pane.cursor1.line, pos=pane.cursor1.pos}
  -- scan current pane down from cursor
  if search_next_in_pane(pane) then
--?     print('found in same pane', pane.cursor1.line, pane.cursor1.pos)
    return
  end
  pane.cursor1 = old_cursor_in_cursor_pane
  -- scan current column down from current pane
  for current_pane_index=Cursor_pane.row+1,#Surface[Cursor_pane.col] do
    local pane = Surface[Cursor_pane.col][current_pane_index]
    pane.cursor1 = {line=1, pos=1}
    edit.check_locs(pane)
    pane.screen_top1 = {line=1, pos=1}
    if search_next_in_pane(pane) then
      Cursor_pane.row = current_pane_index
--?       print('found in same column', Cursor_pane.col, Cursor_pane.row, pane.cursor1.line, pane.cursor1.pos)
      return
    end
  end
  local current_column_index = 1 + Cursor_pane.col%#Surface  -- (i+1)%#Surface in the presence of 1-indexing
  -- scan columns past current, looping around
  while true do
    for current_pane_index,pane in ipairs(Surface[current_column_index]) do
      pane.cursor1 = {line=1, pos=1}
      edit.check_locs(pane)
      pane.screen_top1 = {line=1, pos=1}
      if search_next_in_pane(pane) then
        Cursor_pane = {col=current_column_index, row=current_pane_index}
--?         print('found', Cursor_pane.col, Cursor_pane.row, pane.cursor1.line, pane.cursor1.pos)
        return
      end
    end
    -- loop update
    current_column_index = 1 + current_column_index%#Surface  -- i = (i+1)%#Surface in the presence of 1-indexing
    -- termination check
    if current_column_index == Cursor_pane.col then
      break
    end
  end
  -- scan current column until current pane
  for current_pane_index=1,Cursor_pane.row-1 do
    local pane = Surface[Cursor_pane.col][current_pane_index]
    if search_next_in_pane(pane) then
      Cursor_pane.row = current_pane_index
--?       print('found in same column', Cursor_pane.col, Cursor_pane.row, pane.cursor1.line, pane.cursor1.pos)
      return
    end
  end
  -- finally, scan the cursor pane until the cursor
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  local old_cursor = pane.cursor1
  pane.cursor1 = {line=1, pos=1}
  edit.check_locs(pane)
  pane.screen_top1 = {line=1, pos=1}
  if search_next_in_pane(pane) then
    if Text.lt1(pane.cursor1, old_cursor) then
      return
    end
  end
  -- nothing found
  pane.cursor1 = old_cursor_in_cursor_pane
end

-- returns whether it found an occurrence
function search_next_in_pane(pane)
  pane.search_term = Display_settings.state.search_term
  pane.search_backup = {cursor={line=pane.cursor1.line, pos=pane.cursor1.pos}, screen_top={line=pane.screen_top1.line, pos=pane.screen_top1.pos}}
  for i=1,#pane.lines do
    if pane.line_cache[i] == nil then
      pane.line_cache[i] = {}
    end
  end
  if Text.search_next(pane) then
    if Text.le1(pane.search_backup.cursor, pane.cursor1) then
      -- select this occurrence
      return true
    end
    -- Otherwise cursor wrapped around. Skip this pane.
  end
  -- Clean up this pane before moving on to the next one.
  pane.search_term = nil
  pane.cursor1.line = pane.search_backup.cursor.line
  pane.cursor1.pos = pane.search_backup.cursor.pos
  pane.screen_top1.line = pane.search_backup.screen_top.line
  pane.screen_top1.pos = pane.search_backup.screen_top.pos
  pane.search_backup = nil
end

-- Scan all panes, while delegating as much work as possible to lines.love search.
function search_previous()
  if #Display_settings.state.search_term == 0 then return end
  if Cursor_pane.col < 1 then return end
  clear_all_search_terms()
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    return
  end
--?   print('search previous', Cursor_pane.col, Cursor_pane.row, pane.cursor1.line, pane.cursor1.pos)
  local old_cursor_in_cursor_pane = {line=pane.cursor1.line, pos=pane.cursor1.pos}
  -- scan current pane up from cursor
  if search_previous_in_pane(pane) then
--?     print('found in same pane', pane.cursor1.line, pane.cursor1.pos)
    return
  end
  pane.cursor1 = old_cursor_in_cursor_pane
  -- scan current column down from current pane
  for current_pane_index=Cursor_pane.row-1,1,-1 do
    local pane = Surface[Cursor_pane.col][current_pane_index]
    pane.cursor1 = edit.final_cursor(pane)
    if search_previous_in_pane(pane) then
      Cursor_pane.row = current_pane_index
--?       print('found in same column', Cursor_pane.col, Cursor_pane.row, pane.cursor1.line, pane.cursor1.pos)
      return
    end
  end
  local current_column_index = 1 + (Cursor_pane.col-2)%#Surface  -- (i-1)%#Surface in the presence of 1-indexing
  -- scan columns past current, looping around
  while true do
    for current_pane_index = #Surface[current_column_index],1,-1 do
      local pane = Surface[current_column_index][current_pane_index]
      pane.cursor1 = edit.final_cursor(pane)
      if search_previous_in_pane(pane) then
        Cursor_pane = {col=current_column_index, row=current_pane_index}
--?         print('found', Cursor_pane.col, Cursor_pane.row, pane.cursor1.line, pane.cursor1.pos)
        return
      end
    end
    -- loop update
    current_column_index = 1 + (current_column_index-2)%#Surface  -- i = (i-1)%#Surface in the presence of 1-indexing
    -- termination check
    if current_column_index == Cursor_pane.col then
      break
    end
  end
  -- scan current column from bottom current pane
  for current_pane_index=#Surface[Cursor_pane.col],Cursor_pane.row+1,-1 do
--?     print('same column', current_pane_index)
    local pane = Surface[Cursor_pane.col][current_pane_index]
    if search_previous_in_pane(pane) then
      Cursor_pane.row = current_pane_index
--?       print('found in same column', Cursor_pane.col, Cursor_pane.row, pane.cursor1.line, pane.cursor1.pos)
      return
    end
  end
  -- finally, scan the cursor pane from bottom until cursor
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  local old_cursor = pane.cursor1
  pane.cursor1 = edit.final_cursor(pane)
  if search_previous_in_pane(pane) then
    if Text.lt1(old_cursor, pane.cursor1) then
      return
    end
  end
  -- nothing found
  pane.cursor1 = old_cursor_in_cursor_pane
end

-- returns whether it found an occurrence
function search_previous_in_pane(pane)
  pane.search_term = Display_settings.state.search_term
  pane.search_backup = {cursor={line=pane.cursor1.line, pos=pane.cursor1.pos}, screen_top={line=pane.screen_top1.line, pos=pane.screen_top1.pos}}
  for i=1,#pane.lines do
    if pane.line_cache[i] == nil then
      pane.line_cache[i] = {}
    end
  end
  if Text.search_previous(pane) then
    if Text.lt1(pane.cursor1, pane.search_backup.cursor) then
      -- select this occurrence
      return true
    end
    -- Otherwise cursor wrapped around. Skip this pane.
  end
  -- Clean up this pane before moving on to the previous one.
  pane.search_term = nil
  pane.cursor1.line = pane.search_backup.cursor.line
  pane.cursor1.pos = pane.search_backup.cursor.pos
  pane.screen_top1.line = pane.search_backup.screen_top.line
  pane.screen_top1.pos = pane.search_backup.screen_top.pos
  pane.search_backup = nil
end

function pan_surface_to_screen_top_of_cursor_pane()
  if Cursor_pane.col < 1 then
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    return
  end
  Display_settings.y = up_edge_sy(Cursor_pane.col, Cursor_pane.row) + y_of_schema1(pane, pane.screen_top1)
end

function bring_cursor_of_cursor_pane_in_view(dir)
  if Cursor_pane.col < 1 then
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    return
  end
  local left_edge_sx = left_edge_sx(Cursor_pane.col)
  local cursor_sx = left_edge_sx + Text.x_of_schema1(pane, pane.cursor1)
  local vertically_ok = cursor_sx > Display_settings.x and cursor_sx < Display_settings.x + App.screen.width - pane.font:getWidth('m')
  local cursor_sy = up_edge_sy(Cursor_pane.col, Cursor_pane.row) + y_of_schema1(pane, pane.cursor1)
  local horizontally_ok = cursor_sy > Display_settings.y and cursor_sy < Display_settings.y + App.screen.height - Header_height - 2*Line_height  -- account for search bar along the bottom
  if vertically_ok and horizontally_ok then
    return
  end
  if dir == 'up' then
    if not vertically_ok then
      Display_settings.x = left_edge_sx - Margin_left - Padding_horizontal
    end
    if not horizontally_ok then
      Display_settings.y = cursor_sy - 3*Line_height
    end
  elseif dir == 'down' then
    if not vertically_ok then
      Display_settings.x = left_edge_sx + Display_settings.column_width + Margin_right + Padding_horizontal - App.screen.width
    end
    if not horizontally_ok then
--?       print('subtract', App.screen.height, App.screen.height-Header_height)
      Display_settings.y = cursor_sy + Text.search_bar_height(pane) - (App.screen.height - Header_height)
      -- Bah, temporarily giving up on debugging.
      Display_settings.y = Display_settings.y + Line_height
      assert(App.screen.height - (cursor_sy-Display_settings.y) > 1.5*Line_height, 'ugh, ancient bug is back: panning the viewport when cursor falls off')
    end
  else
    assert(false, ('unknown dir %s'):format(dir))
  end
  Display_settings.x = math.max(Display_settings.x, 0)
  Display_settings.y = math.max(Display_settings.y, 0)
end

function clear_all_search_terms()
  for col,column in ipairs(Surface) do
    for row,pane in ipairs(column) do
      pane.search_term = nil
    end
  end
end

function keychord_press_in_maximize_mode(chord, key)
  if Cursor_pane.col < 1 then
    print_and_log('keychord_press (maximized): no current note to edit')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    print_and_log('keychord_press (maximized): no current note to edit')
    return
  end
  if pane.editable then
    if chord == 'C-e' then
      command.exit_editing()
    else
      edit.keychord_press(pane, chord, key)
    end
  else
    if chord == 'C-e' then
      command.edit_note()
    elseif chord == 'C-c' then
      edit.keychord_press(pane, chord, key)
    end
  end
end

function keychord_press_in_normal_mode_with_immutable_pane(pane, chord, key)
  -- return if no part of cursor pane is visible
  local left_sx = left_edge_sx(Cursor_pane.col)
  if not should_show_column(left_sx) then
    panning_keychord_press(chord, key)
    return
  end
  local up_sy = up_edge_sy(Cursor_pane.col, Cursor_pane.row)
  if not should_show_pane(pane, up_sy) then
    panning_keychord_press(chord, key)
    return
  end
  if chord == 'C-e' then
    command.edit_note()
    profile.start()
  elseif chord == 'C-c' then
    edit.keychord_press(pane, chord, key)
  else
    panning_keychord_press(chord, key)
  end
end

-- y offset of a given (line, pos)
function y_of_schema1(pane, loc)
--?   print(('updating viewport y; cursor pane starts at %d; screen top is at %d,%d'):format(result, loc.line, loc.pos))
  local result = 0
  if pane.title then
    result = result + 5+Line_height+5
  end
  result = result + Margin_above
  if loc.line == 1 and loc.pos == 1 then
    return result
  end
  for i=1,loc.line-1 do
--?   print('', 'd', i, result)
    Text.populate_screen_line_starting_pos(pane, i)
--?     print('', '', #pane.line_cache[i].screen_line_starting_pos, pane.left, pane.right)
    result = result + line_height(pane, i, pane.left, pane.right)
  end
  if pane.lines[loc.line].mode == 'text' then
    Text.populate_screen_line_starting_pos(pane, loc.line)
    for i,screen_line_starting_pos in ipairs(pane.line_cache[loc.line].screen_line_starting_pos) do
      if screen_line_starting_pos >= loc.pos then
        break
      end
      result = result + Line_height
    end
  end
--?   print(('viewport at %d'):format(result))
  return result
end

function keychord_press_in_search_mode(chord, key)
  if chord == 'escape' then
    clear_all_search_terms()
    dehydrate_all_panes()
    -- go back to old viewport
--?     print('esc; exiting search mode')
    Display_settings.x = Display_settings.state.search_backup_x
    Display_settings.y = Display_settings.state.search_backup_y
    Cursor_pane = deepcopy(Display_settings.state.search_backup_cursor_pane)
    Display_settings.state = {id='normal'}
    plan_draw()
  elseif chord == 'return' then
    Display_settings.state = {id='normal'}
    clear_all_search_terms()
    dehydrate_all_panes()
--?     print('return; exiting search mode')
  elseif chord == 'backspace' then
    local len = utf8.len(Display_settings.state.search_term)
    local byte_offset = Text.offset(Display_settings.state.search_term, len)
    Display_settings.state.search_term = string.sub(Display_settings.state.search_term, 1, byte_offset-1)
    -- reset search state
    clear_selections()
    Display_settings.x = Display_settings.state.search_backup_x
    Display_settings.y = Display_settings.state.search_backup_y
    Cursor_pane = deepcopy(Display_settings.state.search_backup_cursor_pane)
    -- search again
    search_next()
    bring_cursor_of_cursor_pane_in_view('down')
    Surface.cursor_on_screen_check = true
    plan_draw()
--?     print('backspace; search term is now', Display_settings.state.search_term)
  elseif chord == 'C-v' then
    Display_settings.state.search_term = Display_settings.state.search_term..App.get_clipboard()
    -- reset search state
    clear_selections()
    Display_settings.x = Display_settings.state.search_backup_x
    Display_settings.y = Display_settings.state.search_backup_y
    Cursor_pane = deepcopy(Display_settings.state.search_backup_cursor_pane)
    -- search again
    search_next()
    bring_cursor_of_cursor_pane_in_view('down')
    Surface.cursor_on_screen_check = true
    plan_draw()
--?     print('paste; search term is now', Display_settings.state.search_term)
  elseif chord == 'up' then
    if Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        search_previous()
        bring_cursor_of_cursor_pane_in_view('up')
        Surface.cursor_on_screen_check = true
        plan_draw()
      end
    end
  elseif chord == 'down' then
    if #Display_settings.state.search_term > 0 and Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        Text.right(pane)
        search_next()
        bring_cursor_of_cursor_pane_in_view('down')
        Surface.cursor_on_screen_check = true
        plan_draw()
      end
    end
  -- things from normal mode we still want
  elseif chord == 'C-c' then
    if Cursor_pane.col >= 1 then
      local pane = Surface[Cursor_pane.col][Cursor_pane.row]
      if pane then
        edit.keychord_press(pane, chord, key)
      end
    end
  end
end

function keychord_press_in_search_all_mode(chord, key)
  if chord == 'escape' then
    Display_settings.state = {id='normal'}
  elseif chord == 'return' then
    finalize_search_all_pane()
    add_search_all_pane_to_right_of_cursor()
    Display_settings.state.id = 'searching_all'
    plan_draw()
  elseif chord == 'backspace' then
    local len = utf8.len(Display_settings.state.search_all_query)
    local byte_offset = Text.offset(Display_settings.state.search_all_query, len)
    Display_settings.state.search_all_query = string.sub(Display_settings.state.search_all_query, 1, byte_offset-1)
--?     print('backspace; search_all term is now', Display_settings.state.search_all_query)
  elseif chord == 'C-v' then
    Display_settings.state.search_all_query = Display_settings.state.search_all_query..App.get_clipboard()
--?     print('paste; search_all term is now', Display_settings.state.search_all_query)
  end
end

-- return (line, pos) of the screen line starting near a given y offset, and
--        y_offset remaining after the calculation
--          invariants:
--            - 0 <= y_offset <= Line_height if line is text
--            - let loc, y_offset = schema1_of_y(pane, y)
--              y - y_offset == y_of_schema1(pane, loc)
function schema1_of_y(pane, y)
  assert(y >= 0, 'something is at negative y on the surface')
  local y_offset = y
  for i=1,#pane.lines do
--?     print('--', y_offset)
    Text.populate_screen_line_starting_pos(pane, i)
    local height = line_height(pane, i, pane.left, pane.right)
    if y_offset < height then
      local line = pane.lines[i]
      if line.mode ~= 'text' then
        return {line=i, pos=1}, y_offset
      else
        local nlines = math.floor(y_offset/pane.line_height)
--?         print(y_offset, pane.line_height, nlines)
        assert(nlines >= 0 and nlines < #pane.line_cache[i].screen_line_starting_pos, 'error in mapping y coordinate to schema-1')
        local pos = pane.line_cache[i].screen_line_starting_pos[nlines+1]  -- switch to 1-indexing
        y_offset = y_offset - nlines*pane.line_height
        return {line=i, pos=pos}, y_offset
      end
    end
    y_offset = y_offset - height
  end
  -- y is below the pane
  return {line=#pane.lines+1, pos=1}, y_offset
end

function line_height(pane, line_index, left, right)
  local line = pane.lines[line_index]
  local line_cache = pane.line_cache[line_index]
  if line.mode == 'text' then
    return pane.line_height*#line_cache.screen_line_starting_pos
  else
    return Drawing.pixels(line.h, right-left) + Drawing_padding_height
  end
end

function stop_editing_all()
  local edit_count = 0
  for _,column in ipairs(Surface) do
    for _,pane in ipairs(column) do
      if pane.editable then
        stop_editing(pane)
        edit_count = edit_count+1
      end
    end
  end
  assert(edit_count <= 1, 'multiple panes were editable')
end

function stop_editing(pane)
  log(2, 'stop_editing: '..pane.id)
  edit.quit(pane)
  -- save symmetric links
  for rel,x in pairs(Links[pane.id]) do
    process_all_links(x, function(target_id)
      log(2, 'stop_editing '..pane.id..': saving links for '..target_id)
      if Links[target_id] then
        save_links(target_id)
      end
    end)
  end
  if Display_settings.state.id ~= 'maximize' then
    refresh_panes(pane)
  end
  pane.editable = false
end

function panning_keychord_press(chord, key)
  if chord == 'up' then
    pan_up()
  elseif chord == 'down' then
    pan_down()
  elseif chord == 'left' then
    pan_left()
  elseif chord == 'right' then
    pan_right()
  elseif chord == 'pageup' or chord == 'S-up' then
    Display_settings.y = math.max(Display_settings.y - App.screen.height + Line_height*2, 0)
    local up_sy = up_edge_sy(Cursor_pane.col, Cursor_pane.row)
    local up_py = up_sy - Display_settings.y
    if up_py > 2/3*App.screen.height then
      Cursor_pane.row = math.min(#Surface[Cursor_pane.col], row(Cursor_pane.col, Display_settings.y + App.screen.height/3))
    end
  elseif chord == 'pagedown' or chord == 'S-down' then
--?     print('pagedown')
    local visible_column_max_y = most(column_height, visible_columns())
    if visible_column_max_y - Display_settings.y > App.screen.height then
--?       print('updating viewport')
      Display_settings.y = Display_settings.y + App.screen.height - Line_height*2
    end
    local down_sx = down_edge_sx(Cursor_pane.col, Cursor_pane.row)
    local down_px = down_sx - Display_settings.y
    if down_px < App.screen.height/3 then
--?       print('updating row')
      Cursor_pane.row = math.min(#Surface[Cursor_pane.col], row(Cursor_pane.col, Display_settings.y + App.screen.height/3))
--?       print('=>', Cursor_pane.row)
    end
  elseif chord == 'S-left' then
    Display_settings.x = math.max(Display_settings.x - Margin_left - Display_settings.column_width - Margin_right - Padding_horizontal, 0)
    local left_sx = left_edge_sx(Cursor_pane.col)
    local left_px = left_sx - Display_settings.x
    if left_px > App.screen.width - Margin_right - Display_settings.column_width/2 then
      Cursor_pane.col = math.min(#Surface, col(Display_settings.x + App.screen.width - Margin_right - Display_settings.column_width/2))
      Cursor_pane.row = math.min(#Surface[Cursor_pane.col], Cursor_pane.row)
    end
  elseif chord == 'S-right' then
    if Display_settings.x < (#Surface-1) * (Padding_horizontal + Margin_left + Display_settings.column_width + Margin_right) then
      Display_settings.x = Display_settings.x + Margin_left + Display_settings.column_width + Margin_right + Padding_horizontal
      local right_sx = left_edge_sx(Cursor_pane.col) + Display_settings.column_width
      local right_px = right_sx - Display_settings.x
      if right_px < Margin_left + Display_settings.column_width/2 then
        Cursor_pane.col = math.min(#Surface, col(Display_settings.x + Margin_left + Display_settings.column_width/2))
        Cursor_pane.row = math.min(#Surface[Cursor_pane.col], Cursor_pane.row)
      end
    end
  elseif chord == 'C-down' then
    command.down_one_pane()
  elseif chord == 'C-up' then
    command.up_one_pane()
  elseif chord == 'C-end' then
    command.bottom_pane_of_column()
  elseif chord == 'C-home' then
    command.top_pane_of_column()
  end
--?   print('after', Cursor_pane.col, Cursor_pane.row)
end

function pan_up()
  Display_settings.y = math.max(Display_settings.y - Pan_step, 0)
  local up_sy = up_edge_sy(Cursor_pane.col, Cursor_pane.row)
  local up_py = up_sy - Display_settings.y
  if up_py > 2/3*App.screen.height then
    Cursor_pane.row = math.min(#Surface[Cursor_pane.col], row(Cursor_pane.col, Display_settings.y + App.screen.height/3))
  end
end

function pan_down()
  local visible_column_max_y = most(column_height, visible_columns())
  if visible_column_max_y - Display_settings.y > App.screen.height/2 then
    Display_settings.y = Display_settings.y + Pan_step
  end
  local down_sx = down_edge_sx(Cursor_pane.col, Cursor_pane.row)
  local down_px = down_sx - Display_settings.y
  if down_px < App.screen.height/3 then
    Cursor_pane.row = math.min(#Surface[Cursor_pane.col], row(Cursor_pane.col, Display_settings.y + App.screen.height/3))
  end
end

function pan_left()
  Display_settings.x = math.max(Display_settings.x - Pan_step, 0)
  local left_sx = left_edge_sx(Cursor_pane.col)
  local left_px = left_sx - Display_settings.x
  if left_px > App.screen.width - Margin_right - Display_settings.column_width/2 then
    Cursor_pane.col = math.min(#Surface, col(Display_settings.x + App.screen.width - Margin_right - Display_settings.column_width/2))
    Cursor_pane.row = math.min(#Surface[Cursor_pane.col], Cursor_pane.row)
  end
end

function pan_right()
  if Display_settings.x < (#Surface-1) * (Padding_horizontal + Margin_left + Display_settings.column_width + Margin_right) then
    Display_settings.x = Display_settings.x + Pan_step
  end
  local right_sx = left_edge_sx(Cursor_pane.col) + Display_settings.column_width
  local right_px = right_sx - Display_settings.x
  if right_px < Margin_left + Display_settings.column_width/2 then
    Cursor_pane.col = math.min(#Surface, col(Display_settings.x + Margin_left + Display_settings.column_width/2))
    Cursor_pane.row = math.min(#Surface[Cursor_pane.col], Cursor_pane.row)
  end
end

function visible_columns()
  local result = {}
  local col = col(Display_settings.x)
  local x = left_edge_sx(col) - Display_settings.x
  while col <= #Surface do
    x = x + Padding_horizontal
    table.insert(result, col)
    x = x + Margin_left + Display_settings.column_width + Margin_right + Padding_horizontal
    if x > App.screen.width then
      break
    end
    col = col+1
  end
  return result
end

function refresh_panes(pane)
--?   print('refreshing')
  for x,col in ipairs(Surface) do
    for y,p in ipairs(col) do
      if p.id == pane.id then
        if p ~= pane then
          Surface[x][y] = load_pane(pane.id)
        end
      end
    end
  end
  plan_draw()
end

-- The basic text editor buffer (editor State) in edit.lua and text.lua
-- assumes there's always an element in line_cache for every line. However,
-- this can be a lot of overhead when we have hundreds of buffers. So pensieve
-- needs to violate this assumption while hiding it from buffer helpers.
function dehydrate_all_panes()
  for x,col in ipairs(Surface) do
    for y,p in ipairs(col) do
      p._height = nil
      p.line_cache = {}
    end
  end
  plan_draw()
end

function rehydrate_pane(pane)
  for i=1,#pane.lines do
    -- don't clobber starty/startpos if they exist
    if pane.line_cache[i] == nil then
      pane.line_cache[i] = {}
    end
    Text.clear_screen_line_cache(pane, i)
  end
end

function run.key_release(key, scancode)
  if Directory_error then return end
--?   print('key release', key)
  Cursor_time = 0  -- ensure cursor is visible immediately after it moves
  if Cursor_pane.col < 1 then
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane and pane.editable then
    edit.key_release(pane, key, scancode)
  end
end

function clear_all_pane_heights()
  for _,column in ipairs(Surface) do
    for _,pane in ipairs(column) do
      pane._height = nil
    end
  end
end

-- convert x surface pixel coordinate into column index
function col(x)
  return 1 + math.floor(x / (Padding_horizontal + Margin_left + Display_settings.column_width + Margin_right))
end

-- col is 1-indexed
-- returns x surface pixel coordinate of left edge of column col
function left_edge_sx(col)
  return (col-1)*(Padding_horizontal + Margin_left + Display_settings.column_width + Margin_right) + Padding_horizontal + Margin_left
end

function row(col, y)
  local sy = Padding_vertical
  for i,pane in ipairs(Surface[col]) do
--?     print('', i, y, sy, next_sy)
    local next_sy = sy + Margin_above + height(pane) + Margin_below + Padding_vertical
    if next_sy > y then
      return i
    end
    sy = next_sy
  end
  return #Surface[col]
end

function up_edge_sy(col, row)
  local result = Padding_vertical
  for i=1,row-1 do
    local pane = Surface[col][i]
    result = result + Margin_above + height(pane) + Margin_below + Padding_vertical
  end
  return result
end

function down_edge_sx(col, row)
  local result = Padding_vertical
  for i=1,row do
    local pane = Surface[col][i]
    result = result + Margin_above + height(pane) + Margin_below + Padding_vertical
  end
  return result - Padding_vertical
end

function column_height(col)
  local result = Padding_vertical
  for pane_index, pane in ipairs(Surface[col]) do
    result = result + Margin_above + height(pane) + Margin_below + Padding_vertical
  end
  return result
end

function most(f, arr)
  local result = nil
  for _,x in ipairs(arr) do
    local curr = f(x)
    if result == nil or result < curr then
      result = curr
    end
  end
  return result
end

function width(s)
  return love.graphics.getFont():getWidth(s)
end

function num_panes()
  local result = 0
  for _,column in ipairs(Surface) do
    result = result+#column
  end
  return result
end