Recently_modified_lookback_window = 100 -- how many notes to populate the 'recently modified' column with -- keep sync'd with Edge_list Opposite = { next='previous', previous='next', side='previous', -- experiment; you can unroll from a side thread and lose the current note child='parent', parent='child', cross='cross', before='after', after='before', link='backlink', backlink='link', } -- keep sync'd with Opposite Edge_list = {'previous', 'next', 'side', 'child', 'parent', 'cross', 'before', 'after', 'link', 'backlink'} -- Most link types/labels are unique; a node can have only one link with each type. -- However there are some exceptions. -- The opposite of a unique link (e.g. parent) might be non-unique (e.g. child). Non_unique_links = {'side', 'child', 'cross', 'before', 'after'} -- Candidate commands to show in in the command palette in different contexts. -- Ideally we'd have rules for: -- contexts to show a command at -- order in which to show commands for each context -- But I don't want to design that, so I'm just going to embrace a -- combinatorial explosion of duplication for a while. Commands = { normal={ 'capture', 'edit note at cursor (ctrl+e)', 'maximize note', 'close column surrounding cursor', 'add (___) (create immediately link)', 'step (___) (open link in new column)', 'extract (open note in new column)', 'unroll (___) (repeatedly step from cursor; unique only)', 'append (___) (repeatedly step, then add; unique only)', 'neighbors (open all links in new column)', 'down one pane (ctrl+down)', 'up one pane (ctrl+up)', 'top pane of column (ctrl+home)', 'bottom pane of column (ctrl+end)', 'grab (temporary second cursor for some commands)', 'ungrab (clear second cursor)', 'link (___) (to second cursor)', 'copy id (of current node to clipboard)', 'rename link ___ (to) ___ (some other label)', 'clear link ___ (use with care! ignores opposite link)', 'move column ___ (after given index; 1 by default)', 'wider columns (X)', 'narrower columns (x)', 'recently modified', 'errors', 'open file ___', 'find on surface (ctrl+f)', 'search (all notes)', 'reload all from disk', 'delete note at cursor from disk (if possible)', 'copy selection to clipboard (ctrl+c)', 'debug stats (toggle)', 'snapshot summary of memory use to disk', }, editable={ 'exit editing (ctrl+e)', 'find in note (ctrl+f)', 'copy selection to clipboard (ctrl+c)', 'cut selection to clipboard (ctrl+x)', 'paste from clipboard (ctrl+v)', 'undo (ctrl+z)', 'redo (ctrl+y)', 'cursor to next word (alt+right arrow)', 'cursor to previous word (alt+left arrow)', 'capture', 'maximize note', 'close column surrounding cursor', 'add (___) (create immediately link)', 'step (___) (open link in new column)', 'unroll (___) (repeatedly step from cursor; unique only)', 'append (___) (repeatedly step, then add; unique only)', 'neighbors (open all links in new column)', 'down one pane (ctrl+down)', 'up one pane (ctrl+up)', 'top pane of column (ctrl+home)', 'bottom pane of column (ctrl+end)', 'wider columns (X)', 'narrower columns (x)', 'recently modified', 'reload all from disk', 'snapshot summary of memory use to disk', }, maximized={ 'back to surface', 'edit note (ctrl+e)', 'add (___) (create immediately link)', 'step (___) (open link in new column)', 'append (___) (repeatedly step, then add; unique only)', 'copy selection to clipboard (ctrl+c)', }, maximized_editable={ 'exit editing (ctrl+e)', 'back to surface', 'find in note (ctrl+f)', 'add (___) (create immediately link)', 'step (___) (open link in new column)', 'append (___) (repeatedly step, then add; unique only)', 'copy selection to clipboard (ctrl+c)', 'cut selection to clipboard (ctrl+x)', 'paste from clipboard (ctrl+v)', 'undo (ctrl+z)', 'redo (ctrl+y)', 'cursor to next word (alt+right arrow)', 'cursor to previous word (alt+left arrow)', }, } -- We incrementally create the menu based on context. Menu_cursor tracks how -- far along the screen width we've gotten. Menu_cursor = 0 Palette_cursor = {y=0, x=0} Palette_alternatives_height = 5 -- number of rows of options to show function draw_menu_bar() if App.run_tests then return end -- disable in tests App.color(Menu_background_color) love.graphics.rectangle('fill', 0,0, App.screen.width, Menu_status_bar_height) App.color(Menu_border_color) love.graphics.rectangle('line', 0,0, App.screen.width, Menu_status_bar_height) if Display_settings.palette then -- TODO: continue to put shortcuts on the menu bar, enter commands/search strings one row down return end App.color(Menu_command_color) Menu_cursor = 5 add_hotkey_to_menu('ctrl+enter: search commands...') App.color(Menu_border_color) love.graphics.line(Menu_cursor-10,2, Menu_cursor-10,Menu_status_bar_height-2) if Display_settings.mode == 'search' then add_hotkey_to_menu('esc: cancel') add_hotkey_to_menu('up: next match') add_hotkey_to_menu('down: previous match') add_hotkey_to_menu('ctrl+v: paste') return end if Cursor_pane.col >= 1 then --? print(Cursor_pane.col, Cursor_pane.row, #Surface) local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane then if Display_settings.mode == 'normal' then if not pane.editable then local left_sx = left_edge_sx(Cursor_pane.col) local up_sy = up_edge_sy(Cursor_pane.col, Cursor_pane.row) if should_show_column(left_sx) and should_show_pane(pane, up_sy) then add_hotkey_to_menu('ctrl+e: edit') add_hotkey_to_menu('ctrl+f: find on surface') add_panning_hotkeys_to_menu() end add_hotkey_to_menu('x/X: narrower/wider columns') else if pane.cursor_x == nil then add_panning_hotkeys_to_menu() else assert(pane.cursor_y, 'cursor fell off viewport') add_hotkey_to_menu('ctrl+e: stop editing') add_hotkey_to_menu('ctrl+h on drawing: help') add_hotkey_to_menu('ctrl+f: find') add_hotkey_to_menu('alt+left alt+right: prev/next word') add_hotkey_to_menu('ctrl+z ctrl+y: undo/redo') add_hotkey_to_menu('ctrl+x ctrl+c ctrl+v: cut/copy/paste') end end end end end add_hotkey_to_menu('ctrl+= ctrl+- ctrl+0: zoom') end function add_panning_hotkeys_to_menu() add_hotkey_to_menu('arrows shift+arrows ctrl+up/down: pan') end function add_hotkey_to_menu(s) local width = Display_settings.font:getWidth(s) if Menu_cursor > App.screen.width - 30 then return end App.color(Menu_command_color) App.screen.print(s, Menu_cursor,5) Menu_cursor = Menu_cursor + width + 30 end function keychord_press_on_command_palette(chord, key) if chord == 'escape' then -- forget text for next command Display_settings.palette = nil elseif chord == 'backspace' then local len = utf8.len(Display_settings.palette.command) local byte_offset = Text.offset(Display_settings.palette.command, len) Display_settings.palette.command = string.sub(Display_settings.palette.command, 1, byte_offset-1) Display_settings.palette.alternative_index = 1 Display_settings.palette.candidates = candidates() elseif chord == 'tab' then -- select top candidate, but don't submit local p = Display_settings.palette p.command = command_string(p.candidates[p.alternative_index]) elseif chord == 'C-v' then local p = Display_settings.palette p.command = p.command..App.get_clipboard() p.candidates = candidates() elseif chord == 'return' then -- submit selected candidate local p = Display_settings.palette local candidates = Display_settings.palette.candidates if #p.candidates > 0 then if file_exists(Directory..p.candidates[p.alternative_index]) then command.open_file_in_next_column(p.candidates[p.alternative_index]) else run_command(command_string(p.candidates[p.alternative_index])) end else -- try to run the command as if it contains args run_command_with_args(p.command) end -- forget text for next command Display_settings.palette = nil -- clean up some columns if possible if Cursor_pane.col < 45 then while #Surface > 50 do print_and_log('keychord_press (palette) return: dropping '..Surface[#Surface].name) table.remove(Surface) end end elseif chord == 'up' then if Display_settings.palette.alternative_index > 1 then Display_settings.palette.alternative_index = Display_settings.palette.alternative_index-1 end elseif chord == 'down' then if Display_settings.palette.alternative_index < #Display_settings.palette.candidates then Display_settings.palette.alternative_index = Display_settings.palette.alternative_index+1 end elseif chord == 'left' then if Display_settings.palette.alternative_index > Palette_alternatives_height then Display_settings.palette.alternative_index = Display_settings.palette.alternative_index-Palette_alternatives_height end elseif chord == 'right' then if Display_settings.palette.alternative_index <= #Display_settings.palette.candidates-Palette_alternatives_height then Display_settings.palette.alternative_index = Display_settings.palette.alternative_index+Palette_alternatives_height end end end function command_string(s) local result, _ = s:gsub(' %(.*', ''):gsub(' _.*', '') return result end function draw_command_palette() -- background App.color(Command_palette_background_color) love.graphics.rectangle('fill', 0,0, App.screen.width, Menu_status_bar_height) App.color(Command_palette_border_color) love.graphics.rectangle('line', 0,0, App.screen.width, Menu_status_bar_height) -- input box App.color(Command_palette_command_color) draw_palette_input(5, 5) -- alternatives App.color(Command_palette_alternatives_background_color) love.graphics.rectangle('fill', 0, Menu_status_bar_height, App.screen.width, 5+Palette_alternatives_height*Line_height+5) App.color(Command_palette_border_color) love.graphics.rectangle('line', 0, Menu_status_bar_height, App.screen.width, 5+Palette_alternatives_height*Line_height+5) Palette_cursor = {y=Menu_status_bar_height+5, x=5, nextx=5} for i,cmd in ipairs(Display_settings.palette.candidates) do add_command_to_palette(cmd, i == Display_settings.palette.alternative_index) end end function draw_command_palette_for_search_all() -- background App.color(Command_palette_background_color) love.graphics.rectangle('fill', 0,0, App.screen.width, Menu_status_bar_height) App.color(Command_palette_border_color) love.graphics.rectangle('line', 0,0, App.screen.width, Menu_status_bar_height) -- input box App.color(Command_palette_command_color) local x = 5 local y = 5 love.graphics.print(Display_settings.search_all_query, x,y) if Display_settings.mode == 'search_all' then -- draw cursor x = x+Display_settings.font:getWidth(Display_settings.search_all_query) draw_cursor(x, y) elseif Display_settings.mode == 'searching_all' then -- show progress App.color(Command_palette_alternatives_background_color) love.graphics.rectangle('fill', 0, Menu_status_bar_height, App.screen.width, 5+Line_height+5) App.color(Command_palette_border_color) love.graphics.rectangle('line', 0, Menu_status_bar_height, App.screen.width, 5+Line_height+5) App.screen.print(Display_settings.search_all_progress_indicator, --[[x]] 5, --[[y]] Menu_status_bar_height+5) end end function add_command_to_palette(s, cursor_highlight) local width = Display_settings.font:getWidth(s) if Palette_cursor.x + width/2 > App.screen.width - 5 then return end if cursor_highlight then App.color(Command_palette_highlighted_alternative_background_color) else App.color(Command_palette_alternatives_background_color) end love.graphics.rectangle('fill', Palette_cursor.x-5, Palette_cursor.y, width+10, Line_height) App.color(Command_palette_alternatives_color) App.screen.print(s, Palette_cursor.x, Palette_cursor.y) Palette_cursor.nextx = math.max(Palette_cursor.nextx, Palette_cursor.x+width+10) Palette_cursor.y = Palette_cursor.y + Line_height if Palette_cursor.y >= Menu_status_bar_height + 5+Palette_alternatives_height*Line_height then App.color(Command_palette_border_color) love.graphics.line(Palette_cursor.nextx, Menu_status_bar_height+2, Palette_cursor.nextx, Menu_status_bar_height + 5+5*Line_height+5) Palette_cursor.x = Palette_cursor.nextx + 5 Palette_cursor.y = Menu_status_bar_height+5 end end function draw_palette_input(x, y) love.graphics.print(Display_settings.palette.command, x,y) x = x+Display_settings.font:getWidth(Display_settings.palette.command) draw_cursor(x, y) end function draw_cursor(x, y) -- blink every 0.5s if math.floor(Cursor_time*2)%2 == 0 then App.color(Cursor_color) love.graphics.rectangle('fill', x,y, 3,Line_height) end end function candidates() -- slight context-sensitive tweaks local candidates = initial_candidates() if Display_settings.palette.command == '' then return candidates elseif Display_settings.palette.command:sub(1,1) == '/' then return {} else local results = filter_candidates(candidates, Display_settings.palette.command) if Display_settings.mode == 'normal' then append(results, file_candidates(Display_settings.palette.command)) end return results end end function initial_candidates() if Cursor_pane.col < 1 then return Commands.normal end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then return Commands.normal end if Display_settings.mode == 'normal' then if not pane.editable then return Commands.normal else return Commands.editable end elseif Display_settings.mode == 'maximize' then if not pane.editable then return Commands.maximized else return Commands.maximized_editable end end end function filter_candidates(candidates, prefix) local result = {} for _,cand in ipairs(candidates) do if cand:find(prefix, 1, --[[literal pattern]] true) == 1 then table.insert(result, cand) end end return result end function file_candidates(prefix) --? print('-- '..prefix) local info = App.file_info(Directory..prefix) --? print(info) --? if info then --? print(info.type) --? end if info and info.type == 'file' then return {prefix} end local path = Directory local visible_dir = '' if info and info.type == 'directory' then if prefix:sub(#prefix) == '/' then visible_dir = prefix else visible_dir = prefix..'/' end path = path..visible_dir elseif prefix:find('/') then visible_dir = dirname(prefix) path = path..visible_dir end --? print('path:', path) local files = App.files(path) --? print(#files, 'files') local base if info and info.type == 'directory' then base = '' else base = basename(prefix) end return concat_all(visible_dir, filter_candidates(reorder(path, files), base)) end function reorder(dir, files) local result = {} local info = {} for _,file in ipairs(files) do info[file] = App.file_info(dir..'/'..file) end -- files before directories for _,file in ipairs(files) do if info[file].type ~= 'directory' then table.insert(result, file) end end for _,file in ipairs(files) do if info[file].type == 'directory' then table.insert(result, file..'/') end end return result end function run_command(cmd, args) if cmd == 'capture' then command.capture() elseif cmd == 'maximize note' then command.maximize_note() elseif cmd == 'back to surface' then command.back_to_surface() elseif cmd == 'edit note' or cmd == 'edit note at cursor' then command.edit_note() elseif cmd == 'exit editing' then command.exit_editing() elseif cmd == 'close column surrounding cursor' then command.close_column_surrounding_cursor() elseif cmd == 'grab' then command.grab() elseif cmd == 'ungrab' then command.ungrab() elseif cmd == 'link' then command.link(args) elseif cmd == 'copy id' then command.copy_id_to_clipboard() elseif cmd == 'rename link' then command.rename_link(args) elseif cmd == 'clear link' then command.clear_link(args) elseif cmd == 'add' then command.add_note(args) elseif cmd == 'step' then command.step(args) elseif cmd == 'extract' then command.extract() elseif cmd == 'unroll' then command.unroll(args) elseif cmd == 'append' then command.append_note(args) elseif cmd == 'neighbors' then command.neighbors() elseif cmd == 'down one pane' then command.down_one_pane() elseif cmd == 'up one pane' then command.up_one_pane() elseif cmd == 'top pane of column' then command.top_pane_of_column() elseif cmd == 'bottom pane of column' then command.bottom_pane_of_column() elseif cmd == 'move column' then command.move_column(args) elseif cmd == 'wider columns' then command.wider_columns() elseif cmd == 'narrower columns' then command.narrower_columns() elseif cmd == 'recently modified' then command.recently_modified() elseif cmd == 'errors' then command.errors() elseif cmd == 'open file' then command.open_file_in_next_column(args) elseif cmd == 'find on surface' then command.commence_find_on_surface() elseif cmd == 'search' then command.commence_search_in_disk() elseif cmd == 'reload all from disk' then command.reload_all() elseif cmd == 'delete note at cursor from disk' then command.delete_note() elseif cmd == 'debug stats' then Display_settings.show_debug = not Display_settings.show_debug elseif cmd == 'snapshot summary of memory use to disk' then command.snapshot_memory() -- editing elseif cmd == 'find in note' then command.send_key_to_current_pane('C-f', 'f') elseif cmd == 'copy selection to clipboard' then command.send_key_to_current_pane('C-c', 'c') elseif cmd == 'cut selection to clipboard' then command.send_key_to_current_pane('C-x', 'x') elseif cmd == 'paste from clipboard' then command.send_key_to_current_pane('C-v', 'v') elseif cmd == 'undo' then command.send_key_to_current_pane('C-z', 'z') elseif cmd == 'redo' then command.send_key_to_current_pane('C-y', 'y') elseif cmd == 'cursor to next word' then command.send_key_to_current_pane('M-right', 'right') elseif cmd == 'cursor to previous word' then command.send_key_to_current_pane('M-left', 'left') else print_and_log(('run_command: not implemented yet: %s'):format(cmd)) end end function run_command_with_args(cmd_with_args) for _,cand in ipairs(initial_candidates()) do cand = command_string(cand) local found_offset = cmd_with_args:find(cand, 1, --[[literal pattern]] true) if found_offset == 1 then local pivot = #cand+1 if cmd_with_args:sub(pivot, pivot) == ' ' then run_command(cand, trim(cmd_with_args:sub(pivot))) end return end end end -- commands that create columns also need to be recreatable from a title -- I'm assuming that special columns have multiple words, and single-word -- columns are always filenames. pensieve.love won't ever create filenames -- containing spaces. function create_column(column_name) if file_exists(Directory..column_name) then local column = {name=column_name} local pane = load_pane(column_name) table.insert(column, pane) table.insert(Surface, column) elseif not column_name:find(' ') then -- File not found -- -- It makes me nervous to silently drop errors, but at this point there's -- really nothing actionable someone can do in response to an error. -- -- Deeper issue: no way yet to communicate errors in the UI. -- -- Philosophical question: what does crash-only mean if you ever run into -- data loss? There's a hard tension between resilience and silent failures. -- -- For now I'm going to rely on all my protections against data loss -- elsewhere. Lines.love has never lost my data in several months of use. -- -- While data loss seems unlikely, there _is_ a legitimate way you can end -- up with a filename that doesn't exist: start a capture, then change -- your mind and never type anything into it. It will continue to show as -- a column on the surface, but there's no file backing it. You can still -- edit it later and create a file for it. But if you just quit, the -- column will silently disappear after restart. print_and_log('create_column: file not found: '..column_name) else -- delegate to one of various helpers based on the column name local column = {name=column_name} populate_column(column) if #column == 0 then -- Something has changed from underneath us, likely between restarts; -- assume we already printed out an error. return end table.insert(Surface, column) end end function populate_column(column) if column.name == 'recently modified' then populate_recently_modified_column(column) elseif column.name == 'errors' then populate_errors_column(column) elseif string.match(column.name, '%S+ from %S+') then local rel, start_id = string.match(column.name, '(%S+) from (%S+)') populate_unroll_column(column, start_id, rel) elseif string.match(column.name, 'neighbors of %S+') then local start_id = string.match(column.name, 'neighbors of (%S+)') populate_neighbors_column(column, start_id) elseif string.match(column.name, '%S+ of %S+') then local rel, start_id = string.match(column.name, '(%S+) of (%S+)') populate_step_column(column, start_id, rel) elseif string.match(column.name, 'search: .+') then --? print('column name', column.name) local search_all_query = string.match(column.name, 'search: (.+)') --? print('search term', search_all_query) populate_search_all_column(column, search_all_query) else error("don't know how to populate column \""..column.name.."\"") end end command = {} function command.capture() local pane = new_pane() local column = {name=pane.id} table.insert(column, pane) table.insert(Surface, Cursor_pane.col+1, column) Cursor_pane.col = Cursor_pane.col+1 Cursor_pane.row = 1 bring_cursor_column_on_screen() stop_editing_all() pane.editable = true command.maximize_note() end function command.maximize_note() Display_settings.mode = 'maximize' end function command.back_to_surface() Display_settings.mode = 'normal' if Cursor_pane.col >= 1 then local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane then refresh_pane_height(pane) plan_draw() end end end function command.close_column_surrounding_cursor() stop_editing_all() table.remove(Surface, Cursor_pane.col) if Cursor_pane.col > 1 then Cursor_pane.col = Cursor_pane.col - 1 Cursor_pane.row = 1 end bring_cursor_column_on_screen() plan_draw() end function command.edit_note() if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end assert(not pane.editable, 'pane already editable') stop_editing_all() pane.recent_updated = false pane.editable = true if Text.lt1(pane.cursor1, pane.screen_top1) then pane.cursor1 = {line=pane.screen_top1.line, pos=pane.screen_top1.pos} end end function command.exit_editing() assert(Cursor_pane.col >= 1, 'no current pane') local pane = Surface[Cursor_pane.col][Cursor_pane.row] assert(pane, 'no current pane') assert(pane.editable, 'current pane not editable') stop_editing(pane) end function command.down_one_pane() if Cursor_pane.row < #Surface[Cursor_pane.col] then Cursor_pane.row = Cursor_pane.row + 1 end Display_settings.y = up_edge_sy(Cursor_pane.col, Cursor_pane.row) - Padding_vertical plan_draw() end function command.up_one_pane() if Cursor_pane.row > 1 then Cursor_pane.row = Cursor_pane.row - 1 end Display_settings.y = up_edge_sy(Cursor_pane.col, Cursor_pane.row) - Padding_vertical plan_draw() end function command.bottom_pane_of_column() if Cursor_pane.row < #Surface[Cursor_pane.col] then Cursor_pane.row = #Surface[Cursor_pane.col] end Display_settings.y = up_edge_sy(Cursor_pane.col, Cursor_pane.row) - Padding_vertical plan_draw() end function command.top_pane_of_column() if Cursor_pane.row > 1 then Cursor_pane.row = 1 end Display_settings.y = up_edge_sy(Cursor_pane.col, Cursor_pane.row) - Padding_vertical plan_draw() end -- move column to _after_ index function command.move_column(index) if index == nil then index = 1 end if Cursor_pane.col < 1 then add_error('no current column') return end local column = Surface[Cursor_pane.col] table.remove(Surface, Cursor_pane.col) table.insert(Surface, index+1, column) Cursor_pane.col = index+1 bring_cursor_column_on_screen() bring_cursor_of_cursor_pane_in_view('down') plan_draw() end function command.wider_columns() Display_settings.column_width = Display_settings.column_width + 5*Display_settings.font:getWidth('m') for _,column in ipairs(Surface) do for _,pane in ipairs(column) do pane.left = 0 pane.right = Display_settings.column_width end end clear_all_pane_heights() plan_draw() end function command.narrower_columns() Display_settings.column_width = Display_settings.column_width - 5*Display_settings.font:getWidth('m') for _,column in ipairs(Surface) do for _,pane in ipairs(column) do pane.left = 0 pane.right = Display_settings.column_width end end clear_all_pane_heights() plan_draw() end function command.recently_modified() if not file_exists(Directory..'recent') then return end local column = {name='recently modified'} populate_recently_modified_column(column) add_column_to_right_of_cursor(column) plan_draw() end function populate_recently_modified_column(column) local filenames = {} local f = App.open_for_reading(Directory..'recent') for line in f:lines() do table.insert(filenames, line) end f:close() local done, ndone = {}, 0 for i=#filenames,1,-1 do local filename = filenames[i] if ndone >= Recently_modified_lookback_window then break end if not done[filename] then done[filename] = true ndone = ndone+1 --? print('loading', filename) local pane = load_pane(filename) table.insert(column, pane) end end end function command.errors() local column = {name='errors'} populate_errors_column(column) add_column_to_right_of_cursor(column) plan_draw() end function populate_errors_column(column) -- TODO: we might run into some bugs if we have multiple error panes visible -- on the surface; unlike everything else these currently alias. local pane = Error_log pane.font_height = Font_height pane.line_height = Line_height pane.editable = false edit.check_locs(pane) pane.title = '(do not edit)' Text.redraw_all(pane) table.insert(column, pane) end function command.open_file_in_next_column(filename) local column = {name=filename} local pane = load_pane(filename) table.insert(column, pane) add_column_to_right_of_cursor(column) plan_draw() end function command.commence_find_on_surface() -- save some state clear_selections() Display_settings.search_backup_x = Display_settings.x Display_settings.search_backup_y = Display_settings.y Display_settings.search_backup_cursor_pane = {row=Cursor_pane.row, col=Cursor_pane.col} -- prepare to pass Display_settings to Text.draw_search_bar --? print('entering search mode') Display_settings.mode = 'search' Display_settings.search_term = '' Display_settings.line_height = Line_height end function command.commence_search_in_disk() Display_settings.mode = 'search_all' Display_settings.search_all_pane = initialize_search_all_pane() Display_settings.search_all_query = '' Display_settings.search_all_progress_indicator = 'starting search...' end -- search panes are opposites of regular panes -- regular pane: pass in id, load from disk, may be edited -- search pane: create without id, initialize id after search term is typed in, create empty file, slowly append to disk, may not be edited function initialize_search_all_pane() 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.font_height = Font_height result.line_height = Line_height result.editable = false return result end function finalize_search_all_pane() if Display_settings.search_all_query:sub(1,1) == '"' then -- support only a single quoted phrase by itself assert(Display_settings.search_all_query:sub(#Display_settings.search_all_query) == '"', 'you can search for strings in quotes, but only one of them by itself') Display_settings.search_all_terms = {Display_settings.search_all_query:sub(2, #Display_settings.search_all_query-1)} else Display_settings.search_all_terms = split(Display_settings.search_all_query) end local id = 'search' --? print(id) App.remove(Directory..id) Display_settings.search_all_pane.id = id Display_settings.search_all_pane.filename = Directory..id Display_settings.editable = false end function add_search_all_pane_to_right_of_cursor() local column = {name='search: '..Display_settings.search_all_query} table.insert(column, Display_settings.search_all_pane) add_column_to_right_of_cursor(column) end function resume_search_all() -- make a little more progress towards searching the whole disk if Display_settings.search_all_progress == nil then Display_settings.search_all_progress_indicator = 'initialized top-level files' Display_settings.search_all_progress = { top_level_files = App.files(Directory), top_level_file_index = 1, } elseif Display_settings.search_all_progress.top_level_file_index then local current_filename = Display_settings.search_all_progress.top_level_files[Display_settings.search_all_progress.top_level_file_index] Display_settings.search_all_progress_indicator = current_filename if current_filename ~= 'search' and current_filename ~= 'config' and current_filename ~= 'recent' then -- ignore some housekeeping files for pensieve.love local info = App.file_info(Directory..current_filename) if info.type == 'file' then search_in_file(current_filename) end end Display_settings.search_all_progress.top_level_file_index = Display_settings.search_all_progress.top_level_file_index+1 if Display_settings.search_all_progress.top_level_file_index > #Display_settings.search_all_progress.top_level_files then Display_settings.search_all_progress.top_level_file_index = nil Display_settings.search_all_progress.top_level_files = nil Display_settings.search_all_progress.time = os.time() Display_settings.search_all_progress.year = os.date('%Y', Display_settings.search_all_progress.time) Display_settings.search_all_progress.date = os.date('%Y/%m/%d/', Display_settings.search_all_progress.time) end elseif Display_settings.search_all_progress.date then -- search one day's directory per frame -- stop when a whole year is missing Display_settings.search_all_progress_indicator = Display_settings.search_all_progress.date local old_year = Display_settings.search_all_progress.year local date_dir = Directory..Display_settings.search_all_progress.date local info = App.file_info(date_dir) if info then if info.type == 'directory' then local filenames = App.files(date_dir) for _,filename in ipairs(filenames) do -- hack: to speed up search, only search files created/managed by Pensieve -- I often have other stuff here that's a lot larger (email). if filename:match('^%d%d%-%d%d%-%d%d$') then --? print(date_dir..filename) search_in_file(Display_settings.search_all_progress.date..filename) end end end end Display_settings.search_all_progress.time = Display_settings.search_all_progress.time - 24*60*60 Display_settings.search_all_progress.year = os.date('%Y', Display_settings.search_all_progress.time) Display_settings.search_all_progress.date = os.date('%Y/%m/%d/', Display_settings.search_all_progress.time) if old_year ~= Display_settings.search_all_progress.year then local previous_year_info = App.file_info(Directory..Display_settings.search_all_progress.year) if previous_year_info == nil then Display_settings.search_all_progress = nil Display_settings.mode = 'normal' end end else assert(false, 'error in search state machine') end end function search_in_file(filename) --? print('searching '..filename..' for '..Display_settings.search_all_query) local contents, err = App.read_file(Directory..filename) if err then error(err) end if contents == nil then error('no contents in '..filename) end if match_all(contents, Display_settings.search_all_terms) then local outfilename = Directory..'search' local success, errmsg = append_to_file(outfilename, '[['..filename..']]\n') if not success then error(errmsg) end local index = 0 while true do index = find_any(contents, Display_settings.search_all_terms, index+1) if index == nil then break end local start_offset = find_previous_byte(contents, '\n', index) local end_offset = contents:find('\n', index, --[[literal]] true) local snippet = contents:sub(start_offset, end_offset) local success, errmsg = append_to_file(outfilename, '...'..snippet..'...\n\n') if not success then error(errmsg) end end load_from_disk(Display_settings.search_all_pane) Text.redraw_all(Display_settings.search_all_pane) refresh_pane_height(Display_settings.search_all_pane) end end function find_previous_byte(s, b, index) while index > 1 do if s:sub(index, index) == b then break end index = index-1 end return index end function interrupt_search_all() if Display_settings.search_all_progress then local outfilename = Directory..'search' local success, errmsg = append_to_file(outfilename, 'interrupted at '..Display_settings.search_all_progress_indicator..'\n') if not success then error(errmsg) end load_from_disk(Display_settings.search_all_pane) Text.redraw_all(Display_settings.search_all_pane) end Display_settings.search_all_progress = nil Display_settings.mode = 'normal' end function populate_search_all_column(column, search_all_query) table.insert(column, load_pane('search')) end function command.reload_all() local column_names = {} for _,column in ipairs(Surface) do table.insert(column_names, column.name) end Surface = {} local old_viewport = {x=Display_settings.x, y=Display_settings.y} local old_cursor_pane = Cursor_pane Cursor_pane = {col=0,row=1} for _,column_name in ipairs(column_names) do create_column(column_name) end Cursor_pane = old_cursor_pane -- something's moving us around Display_settings.x = old_viewport.x Display_settings.y = old_viewport.y plan_draw() end function command.add_note(rel) if rel == nil then rel = 'next' end if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end if Links[pane.id][rel] and not array.find(Non_unique_links, rel) then add_error(('%s already has a %s note'):format(pane.id, rel)) return end stop_editing_all() -- save any edits if #Surface[Cursor_pane.col] == 1 and not array.find(Non_unique_links, rel) then -- column has a single note; turn it into unroll Surface[Cursor_pane.col] = {name=('%s from %s'):format(rel, pane.id)} populate_unroll_column(Surface[Cursor_pane.col], pane.id, rel) end local new_pane = new_pane() new_pane.editable = true -- connect up links add_link(pane.id, rel, new_pane.id) add_link(new_pane.id, Opposite[rel], pane.id) if string.match(Surface[Cursor_pane.col].name, rel..' from %S+') then -- we're unrolling along the same rel; just append to it -- (we couldn't be inserting in the middle if we didn't return earlier in -- the function) table.insert(Surface[Cursor_pane.col], new_pane) Cursor_pane.row = #Surface[Cursor_pane.col] add_title(new_pane, ('%d/%d'):format(Cursor_pane.row, Cursor_pane.row)) refresh_pane_height(pane) -- just in case this is the first link bring_cursor_column_on_screen() bring_cursor_of_cursor_pane_in_view('down') else local column = {name=new_pane.id} table.insert(column, new_pane) add_column_to_right_of_cursor(column) refresh_pane_height(pane) -- just in case this is the first link bring_cursor_of_cursor_pane_in_view('up') end plan_draw() end function command.grab() if Cursor_pane.col >= 1 then local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane then Grab_pane = pane end end end function command.ungrab() Grab_pane = nil end function command.link(rel) if Grab_pane == nil then add_error('link: needs something to be in the secondary "grab" cursor but found nothing') return end if rel == nil then rel = 'next' end if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end if not can_add_link(pane.id, rel, Grab_pane.id) then return end if not can_add_link(Grab_pane.id, Opposite[rel], pane.id) then return end -- connect up links add_link(pane.id, rel, Grab_pane.id) add_link(Grab_pane.id, Opposite[rel], pane.id) schedule_save(pane) stop_editing(pane) refresh_pane_height(pane) -- just in case this is the first link refresh_pane_height(Grab_pane) -- just in case this is the first link Grab_pane = nil plan_draw() end -- Can we add a link called 'rel' from src to target? function can_add_link(src, rel, target) print_and_log(('checking before adding link labeled %s from %s to %s'):format(rel, src, target)) if array.find(Non_unique_links, rel) then -- check if already present if Links[src][rel] then for _,id in ipairs(Links[src][rel]) do if id == target then add_error(('%s is already a %s of %s'):format(target, rel, src)) return false end end end else -- check for conflict if Links[src][rel] then add_error(('%s already has a %s note'):format(src, rel)) return false end end return true end function add_link(src, rel, target) print_and_log(('adding link labeled %s from %s to %s'):format(rel, src, target)) if array.find(Non_unique_links, rel) then if Links[src][rel] == nil then Links[src][rel] = {target} else table.insert(Links[src][rel], target) end else print_and_log('unique link') Links[src][rel] = target end end function links_state(id) local result = {} table.insert(result, id..' -- ') if Links[id] then for rel,val in pairs(Links[id]) do table.insert(result, rel) table.insert(result, ':') if type(val) == 'table' then table.insert(result, '[') for _,dest in ipairs(val) do table.insert(result, dest) table.insert(result, ' ') end table.insert(result, ']') else table.insert(result, val) end table.insert(result, '|') end end return table.concat(result) end function remove_link(src, rel, target) --? print(('removing %s of %s, to %s'):format(rel, src, target)) if array.find(Non_unique_links, rel) then --? print(('%s is non-unique'):format(rel)) local arr = Links[src][rel] assert(arr, 'found no link') assert(type(arr) == 'table', 'links not arranged in a table') local pos = array.find(arr, target) --? print(('contains %s at index %s'):format(target, pos)) assert(pos, "couldn't find link to remove in links table") table.remove(arr, pos) if #arr == 0 then Links[src][rel] = nil end else assert(Links[src][rel] == target, 'link at this rel is not the target; giving up') Links[src][rel] = nil end end function command.copy_id_to_clipboard() if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end App.set_clipboard(pane.id) end function command.rename_link(args) local from, to = args:match('(%w+)%s+to%s+(%w+)') if from == nil then from, to = args:match('(%w+)%s+(%w+)') end if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end local target if array.find(Non_unique_links, from) then if #Links[pane.id][from] <= 0 then add_error(('no %s links'):format(from)) return end if #Links[pane.id][from] > 1 then add_error(('multiple %s links; not sure which one you mean'):format(from)) return end target = Links[pane.id][from][1] else target = Links[pane.id][from] end print_and_log(('renaming link %s of %s to %s'):format(from, pane, to)) if Links[pane.id][to] and not array.find(Non_unique_links, to) then add_error(('%s already has a %s note'):format(pane.id, to)) return end -- forwards direction remove_link(pane.id, from, target) add_link(pane.id, to, target) if Opposite[to] ~= Opposite[from] then remove_link(target, Opposite[from], pane.id) add_link(target, Opposite[to], pane.id) end schedule_save(pane) stop_editing(pane) end function command.clear_link(rel) if rel == nil then add_error('specify a link to clear') return end if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end if Links[pane.id][rel] == nil then add_error(('%s has no %s note'):format(pane.id, rel)) return end if type(Links[pane.id][rel]) == 'table' then if #Links[pane.id][rel] > 1 then add_error(('%s is a non-unique link; clearing all %d such links'):format(rel, #Links[pane.id][rel])) end end print_and_log(('clearing link %s of %s (used to point to %s)'):format(rel, pane.id, Links[pane.id][rel])) Links[pane.id][rel] = nil schedule_save(pane) stop_editing(pane) refresh_pane_height(pane) -- just in case this is the final link plan_draw() end function command.step(rel) if rel == nil then rel = 'next' end if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end if Links[pane.id][rel] == nil then add_error(('%s has no %s note'):format(pane.id, rel)) return end if array.find(Non_unique_links, rel) then local column = {name=(('%s of %s'):format(rel, pane.id))} populate_step_column(column, pane.id, rel) add_column_to_right_of_cursor(column) plan_draw() else command.open_file_in_next_column(Links[pane.id][rel]) end end function populate_step_column(column, start_id, rel) if Links[start_id] == nil then Links[start_id] = load_links(start_id) end if Links[start_id][rel] == nil then return end local n = #Links[start_id][rel] for i, id in ipairs(Links[start_id][rel]) do local pane = load_pane(id) add_title(pane, ('%d/%d'):format(i, n)) table.insert(column, pane) end end function command.extract(rel) if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end command.open_file_in_next_column(pane.id) end function command.unroll(rel) if rel == nil then rel = 'next' end if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end local column = {name=('%s from %s'):format(rel, pane.id)} populate_unroll_column(column, pane.id, rel) if #column == 0 then return end if #Surface[Cursor_pane.col] == 1 then assert(Cursor_pane.row == 1, "couldn't set current pane after unrolling") stop_editing(pane) -- save any edits before we blow it away Surface[Cursor_pane.col] = column else table.insert(Surface, Cursor_pane.col+1, column) Cursor_pane.col = Cursor_pane.col+1 Cursor_pane.row = 1 end bring_cursor_column_on_screen() plan_draw() end function populate_unroll_column(column, id, rel) if Opposite[rel] == rel then add_error(("link type %s is undirected and can't be unrolled"):format(rel)) return end if array.find(Non_unique_links, rel) then add_error(("link type %s is not unique and can't be unrolled"):format(rel)) return end -- back out to start of chain while true do if Links[id] == nil then Links[id] = load_links(id) end if Links[id][Opposite[rel]] == nil then break end id = Links[id][Opposite[rel]] end -- unroll from start local curr = id local n=0 while curr do if Links[curr] == nil then Links[curr] = load_links(curr) end curr = Links[curr][rel] n = n+1 end curr = id local i=1 while curr do local pane = load_pane(curr) add_title(pane, ('%d/%d'):format(i, n)) table.insert(column, pane) curr = Links[curr][rel] i = i+1 end end function command.append_note(rel) if rel == nil then rel = 'next' end if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end if array.find(Non_unique_links, rel) then add_error(('%s is non-unique; which direction should I append?'):format(rel)) return end local curr_id = pane.id while true do if Links[curr_id] == nil then Links[curr_id] = load_links(curr_id) end local next_id = Links[curr_id][rel] if next_id == nil then break end curr_id = next_id end local new_pane = new_pane() stop_editing_all() new_pane.editable = true Links[curr_id][rel] = new_pane.id Links[new_pane.id][Opposite[rel]] = curr_id if #Surface[Cursor_pane.col] == 1 then -- column has a single note; turn it into unroll Surface[Cursor_pane.col] = {name=('%s from %s'):format(rel, pane.id)} populate_unroll_column(Surface[Cursor_pane.col], pane.id, rel) -- invalidates new_pane Cursor_pane.row = #Surface[Cursor_pane.col] Surface[Cursor_pane.col][Cursor_pane.row].editable = true bring_cursor_column_on_screen() bring_cursor_of_cursor_pane_in_view('down') elseif string.match(Surface[Cursor_pane.col].name, rel..' from %S+') then -- we're unrolling along the same rel; just append to it -- (we couldn't be inserting in the middle if we didn't return earlier in -- the function) table.insert(Surface[Cursor_pane.col], new_pane) Cursor_pane.row = #Surface[Cursor_pane.col] add_title(new_pane, ('%d/%d'):format(Cursor_pane.row, Cursor_pane.row)) refresh_pane_height(pane) -- just in case this is the first link bring_cursor_column_on_screen() bring_cursor_of_cursor_pane_in_view('down') else local column = {name=new_pane.id} table.insert(column, new_pane) add_column_to_right_of_cursor(column) bring_cursor_of_cursor_pane_in_view('up') end plan_draw() end function command.neighbors() if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end stop_editing(pane) -- save any edits before we blow it away local column = {name=('neighbors of %s'):format(pane.id)} populate_neighbors_column(column, pane.id) if #Surface[Cursor_pane.col] == 1 then assert(Cursor_pane.row == 1, "couldn't repurpose column for neighbors") Surface[Cursor_pane.col] = column else table.insert(Surface, Cursor_pane.col+1, column) Cursor_pane.col = Cursor_pane.col+1 Cursor_pane.row = 1 end bring_cursor_column_on_screen() plan_draw() end function populate_neighbors_column(column, start_id) table.insert(column, load_pane(start_id)) for rel,x in pairs(Links[start_id]) do process_all_links(x, function(target) local pane = load_pane(target) add_title(pane, rel) table.insert(column, pane) end) end end -- links might contain either a single target or a list of them function process_all_links(x, fn) if type(x) == 'string' then fn(x) else for _,target in ipairs(x) do fn(target) end end end function command.delete_note() if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end if #Links[pane.id] > 0 then add_error('pane has links; giving up') return end -- TODO: test harness support for file ops below if App.run_tests then return end -- delete from disk App.remove(Directory..pane.id) -- delete from recently modified local filenames = {} local f = App.open_for_reading(Directory..'recent') for line in f:lines() do if line ~= pane.id then table.insert(filenames, line) end end f:close() local f = App.open_for_writing(Directory..'recent') for _,filename in ipairs(filenames) do f:write(filename) f:write('\n') end f:close() -- Delete any columns dedicated to just this note, and update cursor pane if necessary. local delete_cursor_column = Surface[Cursor_pane.col].name == pane.id for i=#Surface,Cursor_pane.col+1,-1 do local column = Surface[i] if column.name == pane.id then table.remove(Surface, i) end end local num_deleted = 0 for i=Cursor_pane.col,1,-1 do local column = Surface[i] if column.name == pane.id then table.remove(Surface, i) num_deleted = num_deleted+1 end end if num_deleted > 0 then Cursor_pane.col = Cursor_pane.col - num_deleted if delete_cursor_column then Cursor_pane.row = 1 end else assert(not delete_cursor_column, "failed to delete note's column") end -- while Cursor_pane.row > #Surface[Cursor_pane.col] do Cursor_pane.row = #Surface[Cursor_pane.col] if Cursor_pane.row == 0 then Cursor_pane.col = Cursor_pane.col - 1 Cursor_pane.row = 1 end end -- command.reload_all() end function command.snapshot_memory() -- load library on demand if mri == nil then mri = require('MemoryReferenceInfo') end collectgarbage('collect') print(collectgarbage('count')) mri.m_cMethods.DumpMemorySnapshot('./', 'mem', -1) add_error('snapshot successfully dumped') end function command.send_key_to_current_pane(chord, key) if Cursor_pane.col < 1 then add_error('no current note') return end local pane = Surface[Cursor_pane.col][Cursor_pane.row] if pane == nil then add_error('no current note') return end edit.keychord_press(pane, chord, key) end -- return a new pane with a unique filename function new_pane() local t = os.time() local id = os.date('%Y/%m/%d/%H-%M-%S', t) print_and_log('new_pane: creating directory '..Directory..dirname(id)) local status = App.mkdir(Directory..dirname(id)) assert(status, "failed to create directory for note") Links[id] = {} local pane = load_pane(id) if not file_exists(pane.filename) then table.insert(pane.lines, 1, {data=os.date('%Y-%m-%d %H:%M:%S %Z', t), mode='text'}) pane.cursor1 = {line=2, pos=1} Text.redraw_all(pane) end return pane end function add_column_to_right_of_cursor(column) table.insert(Surface, Cursor_pane.col+1, column) Cursor_pane.col = Cursor_pane.col+1 Cursor_pane.row = 1 bring_cursor_column_on_screen() end function bring_cursor_column_on_screen() local col_sx = left_edge_sx(Cursor_pane.col) if col_sx < Display_settings.x or col_sx > Display_settings.x + App.screen.width - Display_settings.column_width then Display_settings.x = math.max(0, col_sx + Display_settings.column_width + Margin_right + Padding_horizontal - App.screen.width) Display_settings.y = 0 end end function emit_links_in_json_in_consistent_order(outfile, links) local first_written = false outfile:write('{') for _,label in pairs(Edge_list) do if links[label] then if first_written then outfile:write(',') else first_written = true end outfile:write(json.encode(label)..':'..json.encode(links[label])) end end -- links we don't know about, just in case for rel,target in pairs(links) do if Opposite[rel] == nil then if first_written then outfile:write(',') else first_written = true end outfile:write(json.encode(rel)..':'..json.encode(target)) end end outfile:write('}') end function concat_all(dir, files) if dir == '' then return files end for i,file in ipairs(files) do files[i] = dir..file end return files end function append(arr, b) for _,x in ipairs(b) do table.insert(arr, x) end end function split(s) local result = {} for sub in s:gmatch("%S+") do table.insert(result, sub) end return result end function match_all(s, subs) for _,sub in ipairs(subs) do if s:find(sub, 1, --[[literal pattern]] true) == nil then return false end end return true end function find_any(s, subs, start) local result = nil for _,sub in ipairs(subs) do local i = s:find(sub, start, --[[literal pattern]] true) if i then if result == nil then result = i elseif i < result then result = i end end end return result end