diff --git a/Manual_tests.md b/Manual_tests.md index e258940..358ea8b 100644 --- a/Manual_tests.md +++ b/Manual_tests.md @@ -3,17 +3,28 @@ program before it ever runs. However, some things don't have tests yet, either because I don't know how to test them or because I've been lazy. I'll at least record those here. -* Initializing settings: - - from previous session - - Filename as absolute path - - Filename as relative path - - from defaults +Startup: + - terminal log shows unit tests running + +Initializing settings: + - delete app settings, start; window opens running the text editor + - quit while running the text editor, restart; window opens running the text editor in same position+dimensions + - quit while editing source (color; no drawings; no selection), restart; window opens editing source in same position+dimensions + - start out running the text editor, move window, press ctrl+e twice; window is running text editor in same position+dimensions + - start out editing source, move window, press ctrl+e twice; window is editing source in same position+dimensions + - no log file; switching to source works + +Code loading: +* run love with directory; text editor runs +* run love with zip file; text editor runs * How the screen looks. Our tests use a level of indirection to check text and graphics printed to screen, but not the precise pixels they translate to. - where exactly the cursor is drawn to highlight a given character - analogously, how a shape precisely looks as you draw it +* start out running the text editor, press ctrl+e to edit source, make a change to the source, press ctrl+e twice to return to the source editor; the change should be preserved. + ### Other compromises Lua is dynamically typed. Tests can't patch over lack of type-checking. diff --git a/README.md b/README.md index 880b507..8dfa137 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ While editing text: * `ctrl+z` to undo, `ctrl+y` to redo * `ctrl+=` to zoom in, `ctrl+-` to zoom out, `ctrl+0` to reset zoom * `alt+right`/`alt+left` to jump to the next/previous word, respectively +* `ctrl+e` to modify the sources For shortcuts while editing drawings, consult the online help. Either: * hover on a drawing and hit `ctrl+h`, or @@ -78,6 +79,10 @@ found anything amiss: http://akkartik.name/contact * No scrollbars yet. That stuff is hard. +* There are some temporary limitations when editing sources: + - no line drawings + - no selecting text + ## Mirrors and Forks Updates to lines.love can be downloaded from the following mirrors in addition diff --git a/app.lua b/app.lua index cac1303..2135fb0 100644 --- a/app.lua +++ b/app.lua @@ -1,4 +1,4 @@ --- main entrypoint for LÖVE +-- love.run: main entrypoint function for LÖVE -- -- Most apps can just use the default, but we need to override it to -- install a test harness. @@ -11,13 +11,10 @@ -- -- Scroll below this function for more details. function love.run() + App.snapshot_love() -- Tests always run at the start. - App.run_tests() - + App.run_tests_and_initialize() --? print('==') - App.disable_tests() - App.initialize_globals() - App.initialize(love.arg.parseGameArguments(arg), arg) love.timer.step() local dt = 0 @@ -123,6 +120,26 @@ end App = {screen={}} +-- save/restore various framework globals we care about -- only on very first load +function App.snapshot_love() + if Love_snapshot then return end + Love_snapshot = {} + -- save the entire initial font; it doesn't seem reliably recreated using newFont + Love_snapshot.initial_font = love.graphics.getFont() +end + +function App.undo_initialize() + love.graphics.setFont(Love_snapshot.initial_font) +end + +function App.run_tests_and_initialize() + App.load() + App.run_tests() + App.disable_tests() + App.initialize_globals() + App.initialize(love.arg.parseGameArguments(arg), arg) +end + function App.initialize_for_test() App.screen.init({width=100, height=50}) App.screen.contents = {} -- clear screen diff --git a/colorize.lua b/colorize.lua new file mode 100644 index 0000000..c0d2117 --- /dev/null +++ b/colorize.lua @@ -0,0 +1,83 @@ +-- State transitions while colorizing a single line. +-- Just for comments and strings. +-- Limitation: each fragment gets a uniform color so we can only change color +-- at word boundaries. +Next_state = { + normal={ + {prefix='--', target='comment'}, + {prefix='"', target='dstring'}, + {prefix="'", target='sstring'}, + }, + dstring={ + {suffix='"', target='normal'}, + }, + sstring={ + {suffix="'", target='normal'}, + }, + -- comments are a sink +} + +Comments_color = {r=0, g=0, b=1} +String_color = {r=0, g=0.5, b=0.5} +Divider_color = {r=0.7, g=0.7, b=0.7} + +Colors = { + normal=Text_color, + comment=Comments_color, + sstring=String_color, + dstring=String_color +} + +Current_state = 'normal' + +function initialize_color() +--? print('new line') + Current_state = 'normal' +end + +function select_color(frag) +--? print('before', '^'..frag..'$', Current_state) + switch_color_based_on_prefix(frag) +--? print('using color', Current_state, Colors[Current_state]) + App.color(Colors[Current_state]) + switch_color_based_on_suffix(frag) +--? print('state after suffix', Current_state) +end + +function switch_color_based_on_prefix(frag) + if Next_state[Current_state] == nil then + return + end + frag = rtrim(frag) + for _,edge in pairs(Next_state[Current_state]) do + if edge.prefix and find(frag, edge.prefix, nil, --[[plain]] true) == 1 then + Current_state = edge.target + break + end + end +end + +function switch_color_based_on_suffix(frag) + if Next_state[Current_state] == nil then + return + end + frag = rtrim(frag) + for _,edge in pairs(Next_state[Current_state]) do + if edge.suffix and rfind(frag, edge.suffix, nil, --[[plain]] true) == #frag then + Current_state = edge.target + break + end + end +end + +function trim(s) + return s:gsub('^%s+', ''):gsub('%s+$', '') +end + +function ltrim(s) + return s:gsub('^%s+', '') +end + +function rtrim(s) + return s:gsub('%s+$', '') +end diff --git a/commands.lua b/commands.lua new file mode 100644 index 0000000..037205f --- /dev/null +++ b/commands.lua @@ -0,0 +1,100 @@ +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} +Menu_highlight_color = {r=0.5, g=0.7, b=0.3} + +function source.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) + App.color(Menu_command_color) + Menu_cursor = 5 + if Show_file_navigator then + source.draw_file_navigator() + return + end + add_hotkey_to_menu('ctrl+e: run') + if Focus == 'edit' then + add_hotkey_to_menu('ctrl+g: switch file') + if Show_log_browser_side then + add_hotkey_to_menu('ctrl+l: hide log browser') + else + add_hotkey_to_menu('ctrl+l: show log browser') + end + if Editor_state.expanded then + add_hotkey_to_menu('ctrl+b: collapse debug prints') + else + add_hotkey_to_menu('ctrl+b: expand debug prints') + end + add_hotkey_to_menu('ctrl+d: create/edit debug print') + add_hotkey_to_menu('ctrl+f: find in file') + add_hotkey_to_menu('alt+left alt+right: prev/next word') + elseif Focus == 'log_browser' then + -- nothing yet + else + assert(false, 'unknown focus "'..Focus..'"') + end + add_hotkey_to_menu('ctrl+z ctrl+y: undo/redo') + add_hotkey_to_menu('ctrl+x ctrl+c ctrl+v: cut/copy/paste') + add_hotkey_to_menu('ctrl+= ctrl+- ctrl+0: zoom') +end + +function add_hotkey_to_menu(s) + if Text_cache[s] == nil then + Text_cache[s] = App.newText(love.graphics.getFont(), s) + end + local width = App.width(Text_cache[s]) + if Menu_cursor + width > App.screen.width - 5 then + return + end + App.color(Menu_command_color) + App.screen.draw(Text_cache[s], Menu_cursor,5) + Menu_cursor = Menu_cursor + width + 30 +end + +function source.draw_file_navigator() + for i,file in ipairs(File_navigation.candidates) do + if file == 'source' then + App.color(Menu_border_color) + love.graphics.line(Menu_cursor-10,2, Menu_cursor-10,Menu_status_bar_height-2) + end + add_file_to_menu(file, i == File_navigation.index) + end +end + +function add_file_to_menu(s, cursor_highlight) + if Text_cache[s] == nil then + Text_cache[s] = App.newText(love.graphics.getFont(), s) + end + local width = App.width(Text_cache[s]) + if Menu_cursor + width > App.screen.width - 5 then + return + end + if cursor_highlight then + App.color(Menu_highlight_color) + love.graphics.rectangle('fill', Menu_cursor-5,5-2, App.width(Text_cache[s])+5*2,Editor_state.line_height+2*2) + end + App.color(Menu_command_color) + App.screen.draw(Text_cache[s], Menu_cursor,5) + Menu_cursor = Menu_cursor + width + 30 +end + +function keychord_pressed_on_file_navigator(chord, key) + if chord == 'escape' then + Show_file_navigator = false + elseif chord == 'return' then + local candidate = guess_source(File_navigation.candidates[File_navigation.index]..'.lua') + source.switch_to_file(candidate) + Show_file_navigator = false + elseif chord == 'left' then + if File_navigation.index > 1 then + File_navigation.index = File_navigation.index-1 + end + elseif chord == 'right' then + if File_navigation.index < #File_navigation.candidates then + File_navigation.index = File_navigation.index+1 + end + end +end diff --git a/edit.lua b/edit.lua index f5dc1f2..87cf6c2 100644 --- a/edit.lua +++ b/edit.lua @@ -20,15 +20,6 @@ Drawing_padding_height = Drawing_padding_top + Drawing_padding_bottom Same_point_distance = 4 -- pixel distance at which two points are considered the same -utf8 = require 'utf8' - -require 'file' -require 'text' -require 'drawing' -require 'geom' -require 'help' -require 'icons' - edit = {} -- run in both tests and a real run diff --git a/file.lua b/file.lua index 6b36b0f..4e268a7 100644 --- a/file.lua +++ b/file.lua @@ -50,7 +50,6 @@ function save_to_disk(State) outfile:close() end -json = require 'json' function load_drawing(infile_next_line) local drawing = {mode='drawing', h=256/2, points={}, shapes={}, pending={}} while true do diff --git a/keychord.lua b/keychord.lua index ba0a47c..7be57d2 100644 --- a/keychord.lua +++ b/keychord.lua @@ -56,9 +56,17 @@ end array = {} function array.find(arr, elem) - for i,x in ipairs(arr) do - if x == elem then - return i + if type(elem) == 'function' then + for i,x in ipairs(arr) do + if elem(x) then + return i + end + end + else + for i,x in ipairs(arr) do + if x == elem then + return i + end end end return nil diff --git a/log.lua b/log.lua new file mode 100644 index 0000000..f59449c --- /dev/null +++ b/log.lua @@ -0,0 +1,34 @@ +function log(stack_frame_index, obj) + local info = debug.getinfo(stack_frame_index, 'Sl') + local msg + if type(obj) == 'string' then + msg = obj + else + msg = json.encode(obj) + end + love.filesystem.append('log', info.short_src..':'..info.currentline..': '..msg..'\n') +end + +-- for section delimiters we'll use specific Unicode box characters +function log_start(name, stack_frame_index) + if stack_frame_index == nil then + stack_frame_index = 3 + end + log(stack_frame_index, '\u{250c} ' .. name) +end +function log_end(name, stack_frame_index) + if stack_frame_index == nil then + stack_frame_index = 3 + end + log(stack_frame_index, '\u{2518} ' .. name) +end + +function log_new(name, stack_frame_index) + if stack_frame_index == nil then + stack_frame_index = 4 + end + log_end(name, stack_frame_index) + log_start(name, stack_frame_index) +end + +-- vim:noexpandtab diff --git a/log_browser.lua b/log_browser.lua new file mode 100644 index 0000000..f65117f --- /dev/null +++ b/log_browser.lua @@ -0,0 +1,316 @@ +-- environment for immutable logs +-- optionally reads extensions for rendering some types from the source codebase that generated them +-- +-- We won't care too much about long, wrapped lines. If they lines get too +-- long to manage, you need a better, graphical rendering for them. Load +-- functions to render them into the log_render namespace. + +function source.initialize_log_browser_side() + Log_browser_state = edit.initialize_state(Margin_top, Editor_state.right + Margin_right + Margin_left, (Editor_state.right+Margin_right)*2, Editor_state.font_height, Editor_state.line_height) + Log_browser_state.filename = 'log' + load_from_disk(Log_browser_state) -- TODO: pay no attention to Fold + log_browser.parse(Log_browser_state) + Text.redraw_all(Log_browser_state) + Log_browser_state.screen_top1 = {line=1, pos=1} + Log_browser_state.cursor1 = {line=1, pos=nil} +end + +Section_stack = {} +Section_border_color = {r=0.7, g=0.7, b=0.7} +Cursor_line_background_color = {r=0.7, g=0.7, b=0, a=0.1} + +Section_border_padding_horizontal = 30 -- TODO: adjust this based on font height (because we draw text vertically along the borders +Section_border_padding_vertical = 15 -- TODO: adjust this based on font height + +log_browser = {} + +function log_browser.parse(State) + for _,line in ipairs(State.lines) do + if line.data ~= '' then + line.filename, line.line_number, line.data = line.data:match('%[string "([^:]*)"%]:([^:]*):%s*(.*)') + line.filename = guess_source(line.filename) + line.line_number = tonumber(line.line_number) + if line.data:sub(1,1) == '{' then + local data = json.decode(line.data) + if log_render[data.name] then + line.data = data + end + line.section_stack = table.shallowcopy(Section_stack) + elseif line.data:match('\u{250c}') then + line.section_stack = table.shallowcopy(Section_stack) -- as it is at the beginning + local section_name = line.data:match('\u{250c}%s*(.*)') + table.insert(Section_stack, {name=section_name}) + line.section_begin = true + line.section_name = section_name + line.data = nil + elseif line.data:match('\u{2518}') then + local section_name = line.data:match('\u{2518}%s*(.*)') + if array.find(Section_stack, function(x) return x.name == section_name end) then + while table.remove(Section_stack).name ~= section_name do + -- + end + line.section_end = true + line.section_name = section_name + line.data = nil + end + line.section_stack = table.shallowcopy(Section_stack) + else + -- string + line.section_stack = table.shallowcopy(Section_stack) + end + else + line.section_stack = {} + end + end +end + +function table.shallowcopy(x) + return {unpack(x)} +end + +function guess_source(filename) + local possible_source = filename:gsub('%.lua$', '%.splua') + if file_exists(possible_source) then + return possible_source + else + return filename + end +end + +function log_browser.draw(State) + assert(#State.lines == #State.line_cache) + local mouse_line_index = log_browser.line_index(State, App.mouse_x(), App.mouse_y()) + local y = State.top + for line_index = State.screen_top1.line,#State.lines do + App.color(Text_color) + local line = State.lines[line_index] + if y + State.line_height > App.screen.height then break end + local height = State.line_height + if should_show(line) then + local xleft = render_stack_left_margin(State, line_index, line, y) + local xright = render_stack_right_margin(State, line_index, line, y) + if line.section_name then + App.color(Section_border_color) + local section_text = to_text(line.section_name) + if line.section_begin then + local sectiony = y+Section_border_padding_vertical + love.graphics.line(xleft,sectiony, xleft,y+State.line_height) + love.graphics.line(xright,sectiony, xright,y+State.line_height) + love.graphics.line(xleft,sectiony, xleft+50-2,sectiony) + love.graphics.draw(section_text, xleft+50,y) + love.graphics.line(xleft+50+App.width(section_text)+2,sectiony, xright,sectiony) + else assert(line.section_end) + local sectiony = y+State.line_height-Section_border_padding_vertical + love.graphics.line(xleft,y, xleft,sectiony) + love.graphics.line(xright,y, xright,sectiony) + love.graphics.line(xleft,sectiony, xleft+50-2,sectiony) + love.graphics.draw(section_text, xleft+50,y) + love.graphics.line(xleft+50+App.width(section_text)+2,sectiony, xright,sectiony) + end + else + if type(line.data) == 'string' then + local old_left, old_right = State.left,State.right + State.left,State.right = xleft,xright + y = Text.draw(State, line_index, y, --[[startpos]] 1) + State.left,State.right = old_left,old_right + else + height = log_render[line.data.name](line.data, xleft, y, xright-xleft) + end + end + if App.mouse_x() > Log_browser_state.left and line_index == mouse_line_index then + App.color(Cursor_line_background_color) + love.graphics.rectangle('fill', xleft,y, xright-xleft, height) + end + y = y + height + end + end +end + +function render_stack_left_margin(State, line_index, line, y) + if line.section_stack == nil then + -- assertion message + for k,v in pairs(line) do + print(k) + end + end + App.color(Section_border_color) + for i=1,#line.section_stack do + local x = State.left + (i-1)*Section_border_padding_horizontal + love.graphics.line(x,y, x,y+log_browser.height(State, line_index)) + if y < 30 then + love.graphics.print(line.section_stack[i].name, x+State.font_height+5, y+5, --[[vertically]] math.pi/2) + end + if y > App.screen.height-log_browser.height(State, line_index) then + love.graphics.print(line.section_stack[i].name, x+State.font_height+5, App.screen.height-App.width(to_text(line.section_stack[i].name))-5, --[[vertically]] math.pi/2) + end + end + return log_browser.left_margin(State, line) +end + +function render_stack_right_margin(State, line_index, line, y) + App.color(Section_border_color) + for i=1,#line.section_stack do + local x = State.right - (i-1)*Section_border_padding_horizontal + love.graphics.line(x,y, x,y+log_browser.height(State, line_index)) + if y < 30 then + love.graphics.print(line.section_stack[i].name, x, y+5, --[[vertically]] math.pi/2) + end + if y > App.screen.height-log_browser.height(State, line_index) then + love.graphics.print(line.section_stack[i].name, x, App.screen.height-App.width(to_text(line.section_stack[i].name))-5, --[[vertically]] math.pi/2) + end + end + return log_browser.right_margin(State, line) +end + +function should_show(line) + -- Show a line if every single section it's in is expanded. + for i=1,#line.section_stack do + local section = line.section_stack[i] + if not section.expanded then + return false + end + end + return true +end + +function log_browser.left_margin(State, line) + return State.left + #line.section_stack*Section_border_padding_horizontal +end + +function log_browser.right_margin(State, line) + return State.right - #line.section_stack*Section_border_padding_horizontal +end + +function log_browser.update(State, dt) +end + +function log_browser.quit(State) +end + +function log_browser.mouse_pressed(State, x,y, mouse_button) + local line_index = log_browser.line_index(State, x,y) + if line_index == nil then + -- below lower margin + return + end + -- leave some space to click without focusing + local line = State.lines[line_index] + local xleft = log_browser.left_margin(State, line) + local xright = log_browser.right_margin(State, line) + if x < xleft or x > xright then + return + end + -- if it's a section begin/end and the section is collapsed, expand it + -- TODO: how to collapse? + if line.section_begin or line.section_end then + -- HACK: get section reference from next/previous line + local new_section + if line.section_begin then + if line_index < #State.lines then + local next_section_stack = State.lines[line_index+1].section_stack + if next_section_stack then + new_section = next_section_stack[#next_section_stack] + end + end + elseif line.section_end then + if line_index > 1 then + local previous_section_stack = State.lines[line_index-1].section_stack + if previous_section_stack then + new_section = previous_section_stack[#previous_section_stack] + end + end + end + if new_section and new_section.expanded == nil then + new_section.expanded = true + return + end + end + -- open appropriate file in source side + if line.filename ~= Editor_state.filename then + source.switch_to_file(line.filename) + end + -- set cursor + Editor_state.cursor1 = {line=line.line_number, pos=1, posB=nil} + -- make sure it's visible + -- TODO: handle extremely long lines + Editor_state.screen_top1.line = math.max(0, Editor_state.cursor1.line-5) + -- show cursor + Focus = 'edit' + -- expand B side + Editor_state.expanded = true +end + +function log_browser.line_index(State, mx,my) + -- duplicate some logic from log_browser.draw + local y = State.top + for line_index = State.screen_top1.line,#State.lines do + local line = State.lines[line_index] + if should_show(line) then + y = y + log_browser.height(State, line_index) + if my < y then + return line_index + end + if y > App.screen.height then break end + end + end +end + +function log_browser.mouse_released(State, x,y, mouse_button) +end + +function log_browser.textinput(State, t) +end + +function log_browser.keychord_pressed(State, chord, key) + -- move + if chord == 'up' then + while State.screen_top1.line > 1 do + State.screen_top1.line = State.screen_top1.line-1 + if should_show(State.lines[State.screen_top1.line]) then + break + end + end + elseif chord == 'down' then + while State.screen_top1.line < #State.lines do + State.screen_top1.line = State.screen_top1.line+1 + if should_show(State.lines[State.screen_top1.line]) then + break + end + end + elseif chord == 'pageup' then + local y = 0 + while State.screen_top1.line > 1 and y < App.screen.height - 100 do + State.screen_top1.line = State.screen_top1.line - 1 + if should_show(State.lines[State.screen_top1.line]) then + y = y + log_browser.height(State, State.screen_top1.line) + end + end + elseif chord == 'pagedown' then + local y = 0 + while State.screen_top1.line < #State.lines and y < App.screen.height - 100 do + if should_show(State.lines[State.screen_top1.line]) then + y = y + log_browser.height(State, State.screen_top1.line) + end + State.screen_top1.line = State.screen_top1.line + 1 + end + end +end + +function log_browser.height(State, line_index) + local line = State.lines[line_index] + if line.data == nil then + -- section header + return State.line_height + elseif type(line.data) == 'string' then + return State.line_height + else + if line.height == nil then +--? print('nil line height! rendering off screen to calculate') + line.height = log_render[line.data.name](line.data, State.left, App.screen.height, State.right-State.left) + end + return line.height + end +end + +function log_browser.keyreleased(State, key, scancode) +end diff --git a/main.lua b/main.lua index a41d93b..e3f1d93 100644 --- a/main.lua +++ b/main.lua @@ -1,217 +1,260 @@ +-- Entrypoint for the app. You can edit this file from within the app if +-- you're careful. + +-- files that come with LÖVE; we can't edit those from within the app utf8 = require 'utf8' -require 'app' -require 'test' +function load_file_from_source_or_save_directory(filename) + local contents = love.filesystem.read(filename) + local code, err = loadstring(contents, filename) + if code == nil then + error(err) + end + return code() +end -require 'keychord' -require 'button' +json = load_file_from_source_or_save_directory('json.lua') -require 'main_tests' +load_file_from_source_or_save_directory('app.lua') +load_file_from_source_or_save_directory('test.lua') --- delegate most business logic to a layer that can be reused by other projects -require 'edit' -Editor_state = {} +load_file_from_source_or_save_directory('keychord.lua') +load_file_from_source_or_save_directory('button.lua') + +-- both sides require (different parts of) the logging framework +load_file_from_source_or_save_directory('log.lua') + +-- but some files we want to only load sometimes +function App.load() + if love.filesystem.getInfo('config') then + Settings = json.decode(love.filesystem.read('config')) + Current_app = Settings.current_app + end + + if Current_app == nil then + Current_app = 'run' + end + + if Current_app == 'run' then + load_file_from_source_or_save_directory('file.lua') + load_file_from_source_or_save_directory('run.lua') + load_file_from_source_or_save_directory('edit.lua') + load_file_from_source_or_save_directory('text.lua') + load_file_from_source_or_save_directory('search.lua') + load_file_from_source_or_save_directory('select.lua') + load_file_from_source_or_save_directory('undo.lua') + load_file_from_source_or_save_directory('icons.lua') + load_file_from_source_or_save_directory('text_tests.lua') + load_file_from_source_or_save_directory('run_tests.lua') + load_file_from_source_or_save_directory('drawing.lua') + load_file_from_source_or_save_directory('geom.lua') + load_file_from_source_or_save_directory('help.lua') + load_file_from_source_or_save_directory('drawing_tests.lua') + else + load_file_from_source_or_save_directory('source_file.lua') + load_file_from_source_or_save_directory('source.lua') + load_file_from_source_or_save_directory('commands.lua') + load_file_from_source_or_save_directory('source_edit.lua') + load_file_from_source_or_save_directory('log_browser.lua') + load_file_from_source_or_save_directory('source_text.lua') + load_file_from_source_or_save_directory('search.lua') + load_file_from_source_or_save_directory('select.lua') + load_file_from_source_or_save_directory('source_undo.lua') + load_file_from_source_or_save_directory('colorize.lua') + load_file_from_source_or_save_directory('source_text_tests.lua') + load_file_from_source_or_save_directory('source_tests.lua') + end +end --- called both in tests and real run function App.initialize_globals() - -- tests currently mostly clear their own state - - -- a few text objects we can avoid recomputing unless the font changes - Text_cache = {} - - -- blinking cursor - Cursor_time = 0 + if Current_app == 'run' then + run.initialize_globals() + elseif Current_app == 'source' then + source.initialize_globals() + else + assert(false, 'unknown app "'..Current_app..'"') + end -- for hysteresis in a few places - Last_resize_time = App.getTime() Last_focus_time = App.getTime() -- https://love2d.org/forums/viewtopic.php?p=249700 + Last_resize_time = App.getTime() end --- called only for real run function App.initialize(arg) - love.keyboard.setTextInput(true) -- bring up keyboard on touch screen - love.keyboard.setKeyRepeat(true) - - love.graphics.setBackgroundColor(1,1,1) - - if love.filesystem.getInfo('config') then - load_settings() + if Current_app == 'run' then + run.initialize(arg) + elseif Current_app == 'source' then + source.initialize(arg) else - initialize_default_settings() + assert(false, 'unknown app "'..Current_app..'"') end + love.window.setTitle('text.love - '..Current_app) +end - if #arg > 0 then - Editor_state.filename = arg[1] - load_from_disk(Editor_state) - Text.redraw_all(Editor_state) - Editor_state.screen_top1 = {line=1, pos=1} - Editor_state.cursor1 = {line=1, pos=1} - edit.fixup_cursor(Editor_state) +function App.resize(w,h) + if Current_app == 'run' then + if run.resize then run.resize(w,h) end + elseif Current_app == 'source' then + if source.resize then source.resize(w,h) end else - load_from_disk(Editor_state) - Text.redraw_all(Editor_state) - if Editor_state.cursor1.line > #Editor_state.lines or Editor_state.lines[Editor_state.cursor1.line].mode ~= 'text' then - edit.fixup_cursor(Editor_state) - end + assert(false, 'unknown app "'..Current_app..'"') end - love.window.setTitle('lines.love - '..Editor_state.filename) - - if #arg > 1 then - print('ignoring commandline args after '..arg[1]) - end - - if rawget(_G, 'jit') then - jit.off() - jit.flush() - end -end - -function load_settings() - local settings = json.decode(love.filesystem.read('config')) - love.graphics.setFont(love.graphics.newFont(settings.font_height)) - -- maximize window to determine maximum allowable dimensions - App.screen.width, App.screen.height, App.screen.flags = love.window.getMode() - -- set up desired window dimensions - love.window.setPosition(settings.x, settings.y, settings.displayindex) - App.screen.flags.resizable = true - App.screen.flags.minwidth = math.min(App.screen.width, 200) - App.screen.flags.minheight = math.min(App.screen.width, 200) - App.screen.width, App.screen.height = settings.width, settings.height - love.window.setMode(App.screen.width, App.screen.height, App.screen.flags) - Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right, settings.font_height, math.floor(settings.font_height*1.3)) - Editor_state.filename = settings.filename - Editor_state.screen_top1 = settings.screen_top - Editor_state.cursor1 = settings.cursor -end - -function initialize_default_settings() - local font_height = 20 - love.graphics.setFont(love.graphics.newFont(font_height)) - local em = App.newText(love.graphics.getFont(), 'm') - initialize_window_geometry(App.width(em)) - Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right) - Editor_state.font_height = font_height - Editor_state.line_height = math.floor(font_height*1.3) - Editor_state.em = em -end - -function initialize_window_geometry(em_width) - -- maximize window - love.window.setMode(0, 0) -- maximize - App.screen.width, App.screen.height, App.screen.flags = love.window.getMode() - -- shrink height slightly to account for window decoration - App.screen.height = App.screen.height-100 - App.screen.width = 40*em_width - App.screen.flags.resizable = true - App.screen.flags.minwidth = math.min(App.screen.width, 200) - App.screen.flags.minheight = math.min(App.screen.width, 200) - love.window.setMode(App.screen.width, App.screen.height, App.screen.flags) -end - -function App.resize(w, h) ---? print(("Window resized to width: %d and height: %d."):format(w, h)) - App.screen.width, App.screen.height = w, h - Text.redraw_all(Editor_state) - Editor_state.selection1 = {} -- no support for shift drag while we're resizing - Editor_state.right = App.screen.width-Margin_right - Editor_state.width = Editor_state.right-Editor_state.left - Text.tweak_screen_top_and_cursor(Editor_state, Editor_state.left, Editor_state.right) Last_resize_time = App.getTime() end function App.filedropped(file) - -- first make sure to save edits on any existing file - if Editor_state.next_save then - save_to_disk(Editor_state) + if Current_app == 'run' then + if run.filedropped then run.filedropped(file) end + elseif Current_app == 'source' then + if source.filedropped then source.filedropped(file) end + else + assert(false, 'unknown app "'..Current_app..'"') end - -- clear the slate for the new file - App.initialize_globals() - Editor_state.filename = file:getFilename() - file:open('r') - Editor_state.lines = load_from_file(file) - file:close() - Text.redraw_all(Editor_state) - edit.fixup_cursor(Editor_state) - love.window.setTitle('lines.love - '..Editor_state.filename) -end - -function App.draw() - edit.draw(Editor_state) -end - -function App.update(dt) - Cursor_time = Cursor_time + dt - -- some hysteresis while resizing - if App.getTime() < Last_resize_time + 0.1 then - return - end - edit.update(Editor_state, dt) -end - -function love.quit() - edit.quit(Editor_state) - -- save some important settings - local x,y,displayindex = love.window.getPosition() - local filename = Editor_state.filename - if filename:sub(1,1) ~= '/' then - filename = love.filesystem.getWorkingDirectory()..'/'..filename -- '/' should work even on Windows - end - local settings = { - x=x, y=y, displayindex=displayindex, - width=App.screen.width, height=App.screen.height, - font_height=Editor_state.font_height, - filename=filename, - screen_top=Editor_state.screen_top1, cursor=Editor_state.cursor1} - love.filesystem.write('config', json.encode(settings)) -end - -function App.mousepressed(x,y, mouse_button) - Cursor_time = 0 -- ensure cursor is visible immediately after it moves - return edit.mouse_pressed(Editor_state, x,y, mouse_button) -end - -function App.mousereleased(x,y, mouse_button) - Cursor_time = 0 -- ensure cursor is visible immediately after it moves - return edit.mouse_released(Editor_state, x,y, mouse_button) + love.window.setTitle('text.love - '..Current_app) end function App.focus(in_focus) if in_focus then Last_focus_time = App.getTime() end + if Current_app == 'run' then + if run.focus then run.focus(in_focus) end + elseif Current_app == 'source' then + if source.focus then source.focus(in_focus) end + else + assert(false, 'unknown app "'..Current_app..'"') + end end -function App.textinput(t) - -- ignore events for some time after window in focus - if App.getTime() < Last_focus_time + 0.01 then +function App.draw() + if Current_app == 'run' then + run.draw() + elseif Current_app == 'source' then + source.draw() + else + assert(false, 'unknown app "'..Current_app..'"') + end +end + +function App.update(dt) + -- some hysteresis while resizing + if App.getTime() < Last_resize_time + 0.1 then return end - Cursor_time = 0 -- ensure cursor is visible immediately after it moves - return edit.textinput(Editor_state, t) + -- + if Current_app == 'run' then + run.update(dt) + elseif Current_app == 'source' then + source.update(dt) + else + assert(false, 'unknown app "'..Current_app..'"') + end end function App.keychord_pressed(chord, key) - -- ignore events for some time after window in focus + -- ignore events for some time after window in focus (mostly alt-tab) if App.getTime() < Last_focus_time + 0.01 then return end - Cursor_time = 0 -- ensure cursor is visible immediately after it moves - return edit.keychord_pressed(Editor_state, chord, key) + -- + if chord == 'C-e' then + -- carefully save settings + if Current_app == 'run' then + local source_settings = Settings.source + Settings = run.settings() + Settings.source = source_settings + if run.quit then run.quit() end + Current_app = 'source' + elseif Current_app == 'source' then + Settings.source = source.settings() + if source.quit then source.quit() end + Current_app = 'run' + else + assert(false, 'unknown app "'..Current_app..'"') + end + Settings.current_app = Current_app + love.filesystem.write('config', json.encode(Settings)) + -- reboot + load_file_from_source_or_save_directory('main.lua') + App.undo_initialize() + App.run_tests_and_initialize() + return + end + if Current_app == 'run' then + if run.keychord_pressed then run.keychord_pressed(chord, key) end + elseif Current_app == 'source' then + if source.keychord_pressed then source.keychord_pressed(chord, key) end + else + assert(false, 'unknown app "'..Current_app..'"') + end end -function App.keyreleased(key, scancode) - -- ignore events for some time after window in focus +function App.textinput(t) + -- ignore events for some time after window in focus (mostly alt-tab) if App.getTime() < Last_focus_time + 0.01 then return end - Cursor_time = 0 -- ensure cursor is visible immediately after it moves - return edit.key_released(Editor_state, key, scancode) + -- + if Current_app == 'run' then + if run.textinput then run.textinput(t) end + elseif Current_app == 'source' then + if source.textinput then source.textinput(t) end + else + assert(false, 'unknown app "'..Current_app..'"') + end end --- use this sparingly -function to_text(s) - if Text_cache[s] == nil then - Text_cache[s] = App.newText(love.graphics.getFont(), s) +function App.keyreleased(chord, key) + -- ignore events for some time after window in focus (mostly alt-tab) + if App.getTime() < Last_focus_time + 0.01 then + return + end + -- + if Current_app == 'run' then + if run.key_released then run.key_released(chord, key) end + elseif Current_app == 'source' then + if source.key_released then source.key_released(chord, key) end + else + assert(false, 'unknown app "'..Current_app..'"') + end +end + +function App.mousepressed(x,y, mouse_button) +--? print('mouse press', x,y) + if Current_app == 'run' then + if run.mouse_pressed then run.mouse_pressed(x,y, mouse_button) end + elseif Current_app == 'source' then + if source.mouse_pressed then source.mouse_pressed(x,y, mouse_button) end + else + assert(false, 'unknown app "'..Current_app..'"') + end +end + +function App.mousereleased(x,y, mouse_button) + if Current_app == 'run' then + if run.mouse_released then run.mouse_released(x,y, mouse_button) end + elseif Current_app == 'source' then + if source.mouse_released then source.mouse_released(x,y, mouse_button) end + else + assert(false, 'unknown app "'..Current_app..'"') + end +end + +function love.quit() + if Current_app == 'run' then + local source_settings = Settings.source + Settings = run.settings() + Settings.source = source_settings + else + Settings.source = source.settings() + end + Settings.current_app = Current_app + love.filesystem.write('config', json.encode(Settings)) + if Current_app == 'run' then + if run.quit then run.quit() end + elseif Current_app == 'source' then + if source.quit then source.quit() end + else + assert(false, 'unknown app "'..Current_app..'"') end - return Text_cache[s] end diff --git a/run.lua b/run.lua new file mode 100644 index 0000000..bb64aa3 --- /dev/null +++ b/run.lua @@ -0,0 +1,182 @@ +run = {} + +Editor_state = {} + +-- called both in tests and real run +function run.initialize_globals() + -- tests currently mostly clear their own state + + -- a few text objects we can avoid recomputing unless the font changes + Text_cache = {} + + -- blinking cursor + Cursor_time = 0 +end + +-- called only for real run +function run.initialize(arg) + love.keyboard.setTextInput(true) -- bring up keyboard on touch screen + love.keyboard.setKeyRepeat(true) + + love.graphics.setBackgroundColor(1,1,1) + + if Settings then + run.load_settings() + else + run.initialize_default_settings() + end + + if #arg > 0 then + Editor_state.filename = arg[1] + load_from_disk(Editor_state) + Text.redraw_all(Editor_state) + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.cursor1 = {line=1, pos=1} + edit.fixup_cursor(Editor_state) + else + load_from_disk(Editor_state) + Text.redraw_all(Editor_state) + if Editor_state.cursor1.line > #Editor_state.lines or Editor_state.lines[Editor_state.cursor1.line].mode ~= 'text' then + edit.fixup_cursor(Editor_state) + end + end + love.window.setTitle('lines.love - '..Editor_state.filename) + + if #arg > 1 then + print('ignoring commandline args after '..arg[1]) + end + + if rawget(_G, 'jit') then + jit.off() + jit.flush() + end +end + +function run.load_settings() + love.graphics.setFont(love.graphics.newFont(Settings.font_height)) + -- maximize window to determine maximum allowable dimensions + App.screen.width, App.screen.height, App.screen.flags = love.window.getMode() + -- set up desired window dimensions + love.window.setPosition(Settings.x, Settings.y, Settings.displayindex) + App.screen.flags.resizable = true + App.screen.flags.minwidth = math.min(App.screen.width, 200) + App.screen.flags.minheight = math.min(App.screen.width, 200) + App.screen.width, App.screen.height = Settings.width, Settings.height + love.window.setMode(App.screen.width, App.screen.height, App.screen.flags) + Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right, Settings.font_height, math.floor(Settings.font_height*1.3)) + Editor_state.filename = Settings.filename + Editor_state.screen_top1 = Settings.screen_top + Editor_state.cursor1 = Settings.cursor +end + +function run.initialize_default_settings() + local font_height = 20 + love.graphics.setFont(love.graphics.newFont(font_height)) + local em = App.newText(love.graphics.getFont(), 'm') + run.initialize_window_geometry(App.width(em)) + Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right) + Editor_state.font_height = font_height + Editor_state.line_height = math.floor(font_height*1.3) + Editor_state.em = em + Settings = run.settings() +end + +function run.initialize_window_geometry(em_width) + -- maximize window + love.window.setMode(0, 0) -- maximize + App.screen.width, App.screen.height, App.screen.flags = love.window.getMode() + -- shrink height slightly to account for window decoration + App.screen.height = App.screen.height-100 + App.screen.width = 40*em_width + App.screen.flags.resizable = true + App.screen.flags.minwidth = math.min(App.screen.width, 200) + App.screen.flags.minheight = math.min(App.screen.width, 200) + love.window.setMode(App.screen.width, App.screen.height, App.screen.flags) +end + +function run.resize(w, h) +--? print(("Window resized to width: %d and height: %d."):format(w, h)) + App.screen.width, App.screen.height = w, h + Text.redraw_all(Editor_state) + Editor_state.selection1 = {} -- no support for shift drag while we're resizing + Editor_state.right = App.screen.width-Margin_right + Editor_state.width = Editor_state.right-Editor_state.left + Text.tweak_screen_top_and_cursor(Editor_state, Editor_state.left, Editor_state.right) +end + +function run.filedropped(file) + -- first make sure to save edits on any existing file + if Editor_state.next_save then + save_to_disk(Editor_state) + end + -- clear the slate for the new file + App.initialize_globals() + Editor_state.filename = file:getFilename() + file:open('r') + Editor_state.lines = load_from_file(file) + file:close() + Text.redraw_all(Editor_state) + edit.fixup_cursor(Editor_state) + love.window.setTitle('lines.love - '..Editor_state.filename) +end + +function run.draw() + edit.draw(Editor_state) +end + +function run.update(dt) + Cursor_time = Cursor_time + dt + edit.update(Editor_state, dt) +end + +function run.quit() + edit.quit(Editor_state) +end + +function run.settings() + local x,y,displayindex = love.window.getPosition() + local filename = Editor_state.filename + if filename:sub(1,1) ~= '/' then + filename = love.filesystem.getWorkingDirectory()..'/'..filename -- '/' should work even on Windows + end + return { + x=x, y=y, displayindex=displayindex, + width=App.screen.width, height=App.screen.height, + font_height=Editor_state.font_height, + filename=filename, + screen_top=Editor_state.screen_top1, cursor=Editor_state.cursor1 + } +end + +function run.mouse_pressed(x,y, mouse_button) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves + return edit.mouse_pressed(Editor_state, x,y, mouse_button) +end + +function run.mouse_released(x,y, mouse_button) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves + return edit.mouse_released(Editor_state, x,y, mouse_button) +end + +function run.textinput(t) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves + return edit.textinput(Editor_state, t) +end + +function run.keychord_pressed(chord, key) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves + return edit.keychord_pressed(Editor_state, chord, key) +end + +function run.key_released(key, scancode) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves + return edit.key_released(Editor_state, key, scancode) +end + +-- use this sparingly +function to_text(s) + if Text_cache[s] == nil then + Text_cache[s] = App.newText(love.graphics.getFont(), s) + end + return Text_cache[s] +end diff --git a/main_tests.lua b/run_tests.lua similarity index 100% rename from main_tests.lua rename to run_tests.lua diff --git a/search.lua b/search.lua index bd28d58..83545c9 100644 --- a/search.lua +++ b/search.lua @@ -30,8 +30,7 @@ function Text.search_next(State) for i=State.cursor1.line+1,#State.lines do pos = find(State.lines[i].data, State.search_term) if pos then - State.cursor1.line = i - State.cursor1.pos = pos + State.cursor1 = {line=i, pos=pos} break end end @@ -41,8 +40,7 @@ function Text.search_next(State) for i=1,State.cursor1.line-1 do pos = find(State.lines[i].data, State.search_term) if pos then - State.cursor1.line = i - State.cursor1.pos = pos + State.cursor1 = {line=i, pos=pos} break end end @@ -78,8 +76,7 @@ function Text.search_previous(State) for i=State.cursor1.line-1,1,-1 do pos = rfind(State.lines[i].data, State.search_term) if pos then - State.cursor1.line = i - State.cursor1.pos = pos + State.cursor1 = {line=i, pos=pos} break end end @@ -89,8 +86,7 @@ function Text.search_previous(State) for i=#State.lines,State.cursor1.line+1,-1 do pos = rfind(State.lines[i].data, State.search_term) if pos then - State.cursor1.line = i - State.cursor1.pos = pos + State.cursor1 = {line=i, pos=pos} break end end @@ -115,18 +111,18 @@ function Text.search_previous(State) end end -function find(s, pat, i) +function find(s, pat, i, plain) if s == nil then return end - return s:find(pat, i) + return s:find(pat, i, plain) end -function rfind(s, pat, i) +function rfind(s, pat, i, plain) if s == nil then return end local rs = s:reverse() local rpat = pat:reverse() if i == nil then i = #s end local ri = #s - i + 1 - local rendpos = rs:find(rpat, ri) + local rendpos = rs:find(rpat, ri, plain) if rendpos == nil then return nil end local endpos = #s - rendpos + 1 assert (endpos >= #pat) diff --git a/source.lua b/source.lua new file mode 100644 index 0000000..6f2b131 --- /dev/null +++ b/source.lua @@ -0,0 +1,358 @@ +source = {} + +Editor_state = {} + +-- called both in tests and real run +function source.initialize_globals() + -- tests currently mostly clear their own state + + Show_log_browser_side = false + Focus = 'edit' + Show_file_navigator = false + File_navigation = { + candidates = { + 'run', + 'run_tests', + 'log', + 'edit', + 'text', + 'search', + 'select', + 'undo', + 'text_tests', + 'file', + 'source', + 'source_tests', + 'commands', + 'log_browser', + 'source_edit', + 'source_text', + 'source_undo', + 'colorize', + 'source_text_tests', + 'source_file', + 'main', + 'button', + 'keychord', + 'app', + 'test', + 'json', + }, + index = 1, + } + + Menu_status_bar_height = nil -- initialized below + + -- a few text objects we can avoid recomputing unless the font changes + Text_cache = {} + + -- blinking cursor + Cursor_time = 0 +end + +-- called only for real run +function source.initialize() + love.keyboard.setTextInput(true) -- bring up keyboard on touch screen + love.keyboard.setKeyRepeat(true) + + love.graphics.setBackgroundColor(1,1,1) + + if Settings and Settings.source then + source.load_settings() + else + source.initialize_default_settings() + end + + source.initialize_edit_side{'run.lua'} + source.initialize_log_browser_side() + + Menu_status_bar_height = 5 + Editor_state.line_height + 5 + Editor_state.top = Editor_state.top + Menu_status_bar_height + Log_browser_state.top = Log_browser_state.top + Menu_status_bar_height +end + +-- environment for a mutable file of bifolded text +-- TODO: some initialization is also happening in load_settings/initialize_default_settings. Clean that up. +function source.initialize_edit_side(arg) + if #arg > 0 then + Editor_state.filename = arg[1] + load_from_disk(Editor_state) + Text.redraw_all(Editor_state) + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.cursor1 = {line=1, pos=1} + else + load_from_disk(Editor_state) + Text.redraw_all(Editor_state) + end + + if #arg > 1 then + print('ignoring commandline args after '..arg[1]) + end + + -- We currently start out with side B collapsed. + -- Other options: + -- * save all expanded state by line + -- * expand all if any location is in side B + if Editor_state.cursor1.line > #Editor_state.lines then + Editor_state.cursor1 = {line=1, pos=1} + end + if Editor_state.screen_top1.line > #Editor_state.lines then + Editor_state.screen_top1 = {line=1, pos=1} + end + edit.eradicate_locations_after_the_fold(Editor_state) + + if rawget(_G, 'jit') then + jit.off() + jit.flush() + end +end + +function source.load_settings() + local settings = Settings.source + love.graphics.setFont(love.graphics.newFont(settings.font_height)) + -- maximize window to determine maximum allowable dimensions + love.window.setMode(0, 0) -- maximize + Display_width, Display_height, App.screen.flags = love.window.getMode() + -- set up desired window dimensions + App.screen.flags.resizable = true + App.screen.flags.minwidth = math.min(Display_width, 200) + App.screen.flags.minheight = math.min(Display_height, 200) + App.screen.width, App.screen.height = settings.width, settings.height +--? print('setting window from settings:', App.screen.width, App.screen.height) + love.window.setMode(App.screen.width, App.screen.height, App.screen.flags) +--? print('loading source position', settings.x, settings.y, settings.displayindex) + source.set_window_position_from_settings(settings) + Show_log_browser_side = settings.show_log_browser_side + local right = App.screen.width - Margin_right + if Show_log_browser_side then + right = App.screen.width/2 - Margin_right + end + Editor_state = edit.initialize_state(Margin_top, Margin_left, right, settings.font_height, math.floor(settings.font_height*1.3)) + Editor_state.filename = settings.filename + Editor_state.screen_top1 = settings.screen_top + Editor_state.cursor1 = settings.cursor +end + +function source.set_window_position_from_settings(settings) + -- setPosition doesn't quite seem to do what is asked of it on Linux. + love.window.setPosition(settings.x, settings.y-37, settings.displayindex) +end + +function source.initialize_default_settings() + local font_height = 20 + love.graphics.setFont(love.graphics.newFont(font_height)) + local em = App.newText(love.graphics.getFont(), 'm') + source.initialize_window_geometry(App.width(em)) + Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right) + Editor_state.font_height = font_height + Editor_state.line_height = math.floor(font_height*1.3) + Editor_state.em = em +end + +function source.initialize_window_geometry(em_width) + -- maximize window + love.window.setMode(0, 0) -- maximize + Display_width, Display_height, App.screen.flags = love.window.getMode() + -- shrink height slightly to account for window decoration + App.screen.height = Display_height-100 + App.screen.width = 40*em_width + App.screen.flags.resizable = true + App.screen.flags.minwidth = math.min(App.screen.width, 200) + App.screen.flags.minheight = math.min(App.screen.width, 200) + love.window.setMode(App.screen.width, App.screen.height, App.screen.flags) + print('initializing source position') + if Settings == nil then Settings = {} end + if Settings.source == nil then Settings.source = {} end + Settings.source.x, Settings.source.y, Settings.source.displayindex = love.window.getPosition() +end + +function source.resize(w, h) +--? print(("Window resized to width: %d and height: %d."):format(w, h)) + App.screen.width, App.screen.height = w, h + Text.redraw_all(Editor_state) + Editor_state.selection1 = {} -- no support for shift drag while we're resizing + if Show_log_browser_side then + Editor_state.right = App.screen.width/2 - Margin_right + else + Editor_state.right = App.screen.width-Margin_right + end + Log_browser_state.left = App.screen.width/2 + Margin_right + Log_browser_state.right = App.screen.width-Margin_right + Editor_state.width = Editor_state.right-Editor_state.left + Text.tweak_screen_top_and_cursor(Editor_state, Editor_state.left, Editor_state.right) +--? print('end resize') +end + +function source.filedropped(file) + -- first make sure to save edits on any existing file + if Editor_state.next_save then + save_to_disk(Editor_state) + end + -- clear the slate for the new file + Editor_state.filename = file:getFilename() + file:open('r') + Editor_state.lines = load_from_file(file) + file:close() + Text.redraw_all(Editor_state) + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.cursor1 = {line=1, pos=1} +end + +-- a copy of source.filedropped when given a filename +function source.switch_to_file(filename) + -- first make sure to save edits on any existing file + if Editor_state.next_save then + save_to_disk(Editor_state) + end + -- clear the slate for the new file + Editor_state.filename = filename + load_from_disk(Editor_state) + Text.redraw_all(Editor_state) + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.cursor1 = {line=1, pos=1} +end + +function source.draw() + source.draw_menu_bar() + edit.draw(Editor_state) + if Show_log_browser_side then + -- divider + App.color(Divider_color) + love.graphics.rectangle('fill', App.screen.width/2-1,Menu_status_bar_height, 3,App.screen.height) + -- + log_browser.draw(Log_browser_state) + end +end + +function source.update(dt) + Cursor_time = Cursor_time + dt + if App.mouse_x() < Editor_state.right then + edit.update(Editor_state, dt) + elseif Show_log_browser_side then + log_browser.update(Log_browser_state, dt) + end +end + +function source.quit() + edit.quit(Editor_state) + log_browser.quit(Log_browser_state) + -- convert any bifold files here +end + +function source.convert_bifold_text(infilename, outfilename) + local contents = love.filesystem.read(infilename) + contents = contents:gsub('\u{1e}', ';') + love.filesystem.write(outfilename, contents) +end + +function source.settings() + if Current_app == 'source' then +--? print('reading source window position') + Settings.source.x, Settings.source.y, Settings.source.displayindex = love.window.getPosition() + end + local filename = Editor_state.filename + if filename:sub(1,1) ~= '/' then + filename = love.filesystem.getWorkingDirectory()..'/'..filename -- '/' should work even on Windows + end +--? print('saving source settings', Settings.source.x, Settings.source.y, Settings.source.displayindex) + return { + x=Settings.source.x, y=Settings.source.y, displayindex=Settings.source.displayindex, + width=App.screen.width, height=App.screen.height, + font_height=Editor_state.font_height, + filename=filename, + screen_top=Editor_state.screen_top1, cursor=Editor_state.cursor1, + show_log_browser_side=Show_log_browser_side, + focus=Focus, + } +end + +function source.mouse_pressed(x,y, mouse_button) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves +--? print('mouse click', x, y) +--? print(Editor_state.left, Editor_state.right) +--? print(Log_browser_state.left, Log_browser_state.right) + if Editor_state.left <= x and x < Editor_state.right then +--? print('click on edit side') + if Focus ~= 'edit' then + Focus = 'edit' + end + edit.mouse_pressed(Editor_state, x,y, mouse_button) + elseif Show_log_browser_side and Log_browser_state.left <= x and x < Log_browser_state.right then +--? print('click on log_browser side') + if Focus ~= 'log_browser' then + Focus = 'log_browser' + end + log_browser.mouse_pressed(Log_browser_state, x,y, mouse_button) + for _,line_cache in ipairs(Editor_state.line_cache) do line_cache.starty = nil end -- just in case we scroll + end +end + +function source.mouse_released(x,y, mouse_button) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves + if Focus == 'edit' then + return edit.mouse_released(Editor_state, x,y, mouse_button) + else + return log_browser.mouse_released(Log_browser_state, x,y, mouse_button) + end +end + +function source.textinput(t) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves + if Focus == 'edit' then + return edit.textinput(Editor_state, t) + else + return log_browser.textinput(Log_browser_state, t) + end +end + +function source.keychord_pressed(chord, key) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves +--? print('source keychord') + if Show_file_navigator then + keychord_pressed_on_file_navigator(chord, key) + return + end + if chord == 'C-l' then +--? print('C-l') + Show_log_browser_side = not Show_log_browser_side + if Show_log_browser_side then + App.screen.width = Log_browser_state.right + Margin_right + else + App.screen.width = Editor_state.right + Margin_right + end +--? print('setting window:', App.screen.width, App.screen.height) + love.window.setMode(App.screen.width, App.screen.height, App.screen.flags) +--? print('done setting window') + -- try to restore position if possible + -- if the window gets wider the window manager may not respect this + source.set_window_position_from_settings(Settings.source) + return + end + if chord == 'C-g' then + Show_file_navigator = true + File_navigation.index = 1 + return + end + if Focus == 'edit' then + return edit.keychord_pressed(Editor_state, chord, key) + else + return log_browser.keychord_pressed(Log_browser_state, chord, key) + end +end + +function source.key_released(key, scancode) + Cursor_time = 0 -- ensure cursor is visible immediately after it moves + if Focus == 'edit' then + return edit.key_released(Editor_state, key, scancode) + else + return log_browser.keychord_pressed(Log_browser_state, chordkey, scancode) + end +end + +-- use this sparingly +function to_text(s) + if Text_cache[s] == nil then + Text_cache[s] = App.newText(love.graphics.getFont(), s) + end + return Text_cache[s] +end diff --git a/source_edit.lua b/source_edit.lua new file mode 100644 index 0000000..d454467 --- /dev/null +++ b/source_edit.lua @@ -0,0 +1,377 @@ +-- some constants people might like to tweak +Text_color = {r=0, g=0, b=0} +Cursor_color = {r=1, g=0, b=0} +Focus_stroke_color = {r=1, g=0, b=0} -- what mouse is hovering over +Highlight_color = {r=0.7, g=0.7, b=0.9} -- selected text +Fold_color = {r=0, g=0.6, b=0} +Fold_background_color = {r=0, g=0.7, b=0} + +Margin_top = 15 +Margin_left = 25 +Margin_right = 25 + +edit = {} + +-- run in both tests and a real run +function edit.initialize_state(top, left, right, font_height, line_height) -- currently always draws to bottom of screen + local result = { + -- a line of bifold text consists of an A side and an optional B side, each of which is a string + -- expanded: whether to show B side + lines = {{data='', dataB=nil, expanded=nil}}, -- array of lines + + -- Lines can be too long to fit on screen, in which case they _wrap_ into + -- multiple _screen lines_. + + -- rendering wrapped text lines needs some additional short-lived data per line: + -- startpos, the index of data the line starts rendering from, can only be >1 for topmost line on screen + -- starty, the y coord in pixels the line starts rendering from + -- fragments: snippets of rendered love.graphics.Text, guaranteed to not straddle screen lines + -- screen_line_starting_pos: optional array of grapheme indices if it wraps over more than one screen line + line_cache = {}, + + -- Given wrapping, any potential location for the text cursor can be described in two ways: + -- * schema 1: As a combination of line index and position within a line (in utf8 codepoint units) + -- * schema 2: As a combination of line index, screen line index within the line, and a position within the screen line. + -- Positions (and screen line indexes) can be in either the A or the B side. + -- + -- Most of the time we'll only persist positions in schema 1, translating to + -- schema 2 when that's convenient. + -- + -- Make sure these coordinates are never aliased, so that changing one causes + -- action at a distance. + screen_top1 = {line=1, pos=1, posB=nil}, -- position of start of screen line at top of screen + cursor1 = {line=1, pos=1, posB=nil}, -- position of cursor + screen_bottom1 = {line=1, pos=1, posB=nil}, -- position of start of screen line at bottom of screen + + -- cursor coordinates in pixels + cursor_x = 0, + cursor_y = 0, + + font_height = font_height, + line_height = line_height, + em = App.newText(love.graphics.getFont(), 'm'), -- widest possible character width + + top = top, + left = left, + right = right, + width = right-left, + + filename = love.filesystem.getUserDirectory()..'/lines.txt', + next_save = nil, + + -- undo + history = {}, + next_history = 1, + + -- search + search_term = nil, + search_text = nil, + search_backup = nil, -- stuff to restore when cancelling search + } + return result +end -- App.initialize_state + +function edit.draw(State) + State.button_handlers = {} + App.color(Text_color) + assert(#State.lines == #State.line_cache) + if not Text.le1(State.screen_top1, State.cursor1) then + print(State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.cursor1.posB) + assert(false) + end + State.cursor_x = nil + State.cursor_y = nil + local y = State.top +--? print('== draw') + for line_index = State.screen_top1.line,#State.lines do + local line = State.lines[line_index] +--? print('draw:', y, line_index, line) + if y + State.line_height > App.screen.height then break end + State.screen_bottom1 = {line=line_index, pos=nil, posB=nil} +--? print('text.draw', y, line_index) + local startpos, startposB = 1, nil + if line_index == State.screen_top1.line then + if State.screen_top1.pos then + startpos = State.screen_top1.pos + else + startpos, startposB = nil, State.screen_top1.posB + end + end + y, State.screen_bottom1.pos, State.screen_bottom1.posB = Text.draw(State, line_index, y, startpos, startposB) + y = y + State.line_height +--? print('=> y', y) + end + if State.search_term then + Text.draw_search_bar(State) + end +end + +function edit.update(State, dt) + if State.next_save and State.next_save < App.getTime() then + save_to_disk(State) + State.next_save = nil + end +end + +function schedule_save(State) + if State.next_save == nil then + State.next_save = App.getTime() + 3 -- short enough that you're likely to still remember what you did + end +end + +function edit.quit(State) + -- make sure to save before quitting + if State.next_save then + save_to_disk(State) + end +end + +function edit.mouse_pressed(State, x,y, mouse_button) + if State.search_term then return end +--? print('press', State.selection1.line, State.selection1.pos) + if mouse_press_consumed_by_any_button_handler(State, x,y, mouse_button) then + -- press on a button and it returned 'true' to short-circuit + return + end + + for line_index,line in ipairs(State.lines) do + if Text.in_line(State, line_index, x,y) then + local pos,posB = Text.to_pos_on_line(State, line_index, x, y) +--? print(x,y, 'setting cursor:', line_index, pos, posB) + State.cursor1 = {line=line_index, pos=pos, posB=posB} + break + end + end +end + +function edit.mouse_released(State, x,y, mouse_button) +end + +function edit.textinput(State, t) + for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll + if State.search_term then + State.search_term = State.search_term..t + State.search_text = nil + Text.search_next(State) + else + Text.textinput(State, t) + end + schedule_save(State) +end + +function edit.keychord_pressed(State, chord, key) + if State.search_term then + if chord == 'escape' then + State.search_term = nil + State.search_text = nil + State.cursor1 = State.search_backup.cursor + State.screen_top1 = State.search_backup.screen_top + State.search_backup = nil + Text.redraw_all(State) -- if we're scrolling, reclaim all fragments to avoid memory leaks + elseif chord == 'return' then + State.search_term = nil + State.search_text = nil + State.search_backup = nil + elseif chord == 'backspace' then + local len = utf8.len(State.search_term) + local byte_offset = Text.offset(State.search_term, len) + State.search_term = string.sub(State.search_term, 1, byte_offset-1) + State.search_text = nil + elseif chord == 'down' then + if State.cursor1.pos then + State.cursor1.pos = State.cursor1.pos+1 + else + State.cursor1.posB = State.cursor1.posB+1 + end + Text.search_next(State) + elseif chord == 'up' then + Text.search_previous(State) + end + return + elseif chord == 'C-f' then + State.search_term = '' + State.search_backup = { + cursor={line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}, + screen_top={line=State.screen_top1.line, pos=State.screen_top1.pos, posB=State.screen_top1.posB}, + } + assert(State.search_text == nil) + -- bifold text + elseif chord == 'C-b' then + State.expanded = not State.expanded + Text.redraw_all(State) + if not State.expanded then + for _,line in ipairs(State.lines) do + line.expanded = nil + end + edit.eradicate_locations_after_the_fold(State) + end + elseif chord == 'C-d' then + if State.cursor1.posB == nil then + local before = snapshot(State, State.cursor1.line) + if State.lines[State.cursor1.line].dataB == nil then + State.lines[State.cursor1.line].dataB = '' + end + State.lines[State.cursor1.line].expanded = true + State.cursor1.pos = nil + State.cursor1.posB = 1 + if Text.cursor_out_of_screen(State) then + Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right) + end + schedule_save(State) + record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)}) + end + -- zoom + elseif chord == 'C-=' then + edit.update_font_settings(State, State.font_height+2) + Text.redraw_all(State) + elseif chord == 'C--' then + edit.update_font_settings(State, State.font_height-2) + Text.redraw_all(State) + elseif chord == 'C-0' then + edit.update_font_settings(State, 20) + Text.redraw_all(State) + -- undo + elseif chord == 'C-z' then + for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll + local event = undo_event(State) + if event then + local src = event.before + State.screen_top1 = deepcopy(src.screen_top) + State.cursor1 = deepcopy(src.cursor) + patch(State.lines, event.after, event.before) + patch_placeholders(State.line_cache, event.after, event.before) + -- if we're scrolling, reclaim all fragments to avoid memory leaks + Text.redraw_all(State) + schedule_save(State) + end + elseif chord == 'C-y' then + for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll + local event = redo_event(State) + if event then + local src = event.after + State.screen_top1 = deepcopy(src.screen_top) + State.cursor1 = deepcopy(src.cursor) + patch(State.lines, event.before, event.after) + -- if we're scrolling, reclaim all fragments to avoid memory leaks + Text.redraw_all(State) + schedule_save(State) + end + -- clipboard + elseif chord == 'C-c' then + local s = Text.selection(State) + if s then + App.setClipboardText(s) + end + elseif chord == 'C-x' then + for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll + local s = Text.cut_selection(State, State.left, State.right) + if s then + App.setClipboardText(s) + end + schedule_save(State) + elseif chord == 'C-v' then + for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll + -- We don't have a good sense of when to scroll, so we'll be conservative + -- and sometimes scroll when we didn't quite need to. + local before_line = State.cursor1.line + local before = snapshot(State, before_line) + local clipboard_data = App.getClipboardText() + for _,code in utf8.codes(clipboard_data) do + local c = utf8.char(code) + if c == '\n' then + Text.insert_return(State) + else + Text.insert_at_cursor(State, c) + end + end + if Text.cursor_out_of_screen(State) then + Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right) + end + schedule_save(State) + record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)}) + -- dispatch to text + else + for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll + Text.keychord_pressed(State, chord) + end +end + +function edit.eradicate_locations_after_the_fold(State) + -- eradicate side B from any locations we track + if State.cursor1.posB then + State.cursor1.posB = nil + State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + State.cursor1.pos = Text.pos_at_start_of_screen_line(State, State.cursor1) + end + if State.screen_top1.posB then + State.screen_top1.posB = nil + State.screen_top1.pos = utf8.len(State.lines[State.screen_top1.line].data) + State.screen_top1.pos = Text.pos_at_start_of_screen_line(State, State.screen_top1) + end +end + +function edit.key_released(State, key, scancode) +end + +function edit.update_font_settings(State, font_height) + State.font_height = font_height + love.graphics.setFont(love.graphics.newFont(Editor_state.font_height)) + State.line_height = math.floor(font_height*1.3) + State.em = App.newText(love.graphics.getFont(), 'm') + Text_cache = {} +end + +--== some methods for tests + +Test_margin_left = 25 + +function edit.initialize_test_state() + -- if you change these values, tests will start failing + return edit.initialize_state( + 15, -- top margin + Test_margin_left, + App.screen.width, -- right margin = 0 + 14, -- font height assuming default LÖVE font + 15) -- line height +end + +-- all textinput events are also keypresses +-- TODO: handle chords of multiple keys +function edit.run_after_textinput(State, t) + edit.keychord_pressed(State, t) + edit.textinput(State, t) + edit.key_released(State, t) + App.screen.contents = {} + edit.draw(State) +end + +-- not all keys are textinput +function edit.run_after_keychord(State, chord) + edit.keychord_pressed(State, chord) + edit.key_released(State, chord) + App.screen.contents = {} + edit.draw(State) +end + +function edit.run_after_mouse_click(State, x,y, mouse_button) + App.fake_mouse_press(x,y, mouse_button) + edit.mouse_pressed(State, x,y, mouse_button) + App.fake_mouse_release(x,y, mouse_button) + edit.mouse_released(State, x,y, mouse_button) + App.screen.contents = {} + edit.draw(State) +end + +function edit.run_after_mouse_press(State, x,y, mouse_button) + App.fake_mouse_press(x,y, mouse_button) + edit.mouse_pressed(State, x,y, mouse_button) + App.screen.contents = {} + edit.draw(State) +end + +function edit.run_after_mouse_release(State, x,y, mouse_button) + App.fake_mouse_release(x,y, mouse_button) + edit.mouse_released(State, x,y, mouse_button) + App.screen.contents = {} + edit.draw(State) +end diff --git a/source_file.lua b/source_file.lua new file mode 100644 index 0000000..978e949 --- /dev/null +++ b/source_file.lua @@ -0,0 +1,89 @@ +-- primitives for saving to file and loading from file + +Fold = '\x1e' -- ASCII RS (record separator) + +function file_exists(filename) + local infile = App.open_for_reading(filename) + if infile then + infile:close() + return true + else + return false + end +end + +function load_from_disk(State) + local infile = App.open_for_reading(State.filename) + State.lines = load_from_file(infile) + if infile then infile:close() end +end + +function load_from_file(infile) + local result = {} + if infile then + local infile_next_line = infile:lines() -- works with both Lua files and LÖVE Files (https://www.love2d.org/wiki/File) + while true do + local line = infile_next_line() + if line == nil then break end + local line_info = {} + if line:find(Fold) then + _, _, line_info.data, line_info.dataB = line:find('([^'..Fold..']*)'..Fold..'([^'..Fold..']*)') + else + line_info.data = line + end + table.insert(result, line_info) + end + end + if #result == 0 then + table.insert(result, {data=''}) + end + return result +end + +function save_to_disk(State) + local outfile = App.open_for_writing(State.filename) + if outfile == nil then + error('failed to write to "'..State.filename..'"') + end + for _,line in ipairs(State.lines) do + outfile:write(line.data) + if line.dataB and #line.dataB > 0 then + outfile:write(Fold) + outfile:write(line.dataB) + end + outfile:write('\n') + end + outfile:close() +end + +function file_exists(filename) + local infile = App.open_for_reading(filename) + if infile then + infile:close() + return true + else + return false + end +end + +-- for tests +function load_array(a) + local result = {} + local next_line = ipairs(a) + local i,line,drawing = 0, '' + while true do + i,line = next_line(a, i) + if i == nil then break end + local line_info = {} + if line:find(Fold) then + _, _, line_info.data, line_info.dataB = line:find('([^'..Fold..']*)'..Fold..'([^'..Fold..']*)') + else + line_info.data = line + end + table.insert(result, line_info) + end + if #result == 0 then + table.insert(result, {data=''}) + end + return result +end diff --git a/source_tests.lua b/source_tests.lua new file mode 100644 index 0000000..dde4ec4 --- /dev/null +++ b/source_tests.lua @@ -0,0 +1,77 @@ +function test_resize_window() + io.write('\ntest_resize_window') + App.screen.init{width=300, height=300} + Editor_state = edit.initialize_test_state() + Editor_state.filename = 'foo' + Log_browser_state = edit.initialize_test_state() + check_eq(App.screen.width, 300, 'F - test_resize_window/baseline/width') + check_eq(App.screen.height, 300, 'F - test_resize_window/baseline/height') + check_eq(Editor_state.left, Test_margin_left, 'F - test_resize_window/baseline/left_margin') + App.resize(200, 400) + check_eq(App.screen.width, 200, 'F - test_resize_window/width') + check_eq(App.screen.height, 400, 'F - test_resize_window/height') + check_eq(Editor_state.left, Test_margin_left, 'F - test_resize_window/left_margin') + -- ugly; right margin switches from 0 after resize + check_eq(Editor_state.right, 200-Margin_right, 'F - test_resize_window/right_margin') + check_eq(Editor_state.width, 200-Test_margin_left-Margin_right, 'F - test_resize_window/drawing_width') + -- TODO: how to make assertions about when App.update got past the early exit? +end + +function test_drop_file() + io.write('\ntest_drop_file') + App.screen.init{width=Editor_state.left+300, height=300} + Editor_state = edit.initialize_test_state() + App.filesystem['foo'] = 'abc\ndef\nghi\n' + local fake_dropped_file = { + opened = false, + getFilename = function(self) + return 'foo' + end, + open = function(self) + self.opened = true + end, + lines = function(self) + assert(self.opened) + return App.filesystem['foo']:gmatch('[^\n]+') + end, + close = function(self) + self.opened = false + end, + } + App.filedropped(fake_dropped_file) + check_eq(#Editor_state.lines, 3, 'F - test_drop_file/#lines') + check_eq(Editor_state.lines[1].data, 'abc', 'F - test_drop_file/lines:1') + check_eq(Editor_state.lines[2].data, 'def', 'F - test_drop_file/lines:2') + check_eq(Editor_state.lines[3].data, 'ghi', 'F - test_drop_file/lines:3') + edit.draw(Editor_state) +end + +function test_drop_file_saves_previous() + io.write('\ntest_drop_file_saves_previous') + App.screen.init{width=Editor_state.left+300, height=300} + -- initially editing a file called foo that hasn't been saved to filesystem yet + Editor_state.lines = load_array{'abc', 'def'} + Editor_state.filename = 'foo' + schedule_save(Editor_state) + -- now drag a new file bar from the filesystem + App.filesystem['bar'] = 'abc\ndef\nghi\n' + local fake_dropped_file = { + opened = false, + getFilename = function(self) + return 'bar' + end, + open = function(self) + self.opened = true + end, + lines = function(self) + assert(self.opened) + return App.filesystem['bar']:gmatch('[^\n]+') + end, + close = function(self) + self.opened = false + end, + } + App.filedropped(fake_dropped_file) + -- filesystem now contains a file called foo + check_eq(App.filesystem['foo'], 'abc\ndef\n', 'F - test_drop_file_saves_previous') +end diff --git a/source_text.lua b/source_text.lua new file mode 100644 index 0000000..e491dac --- /dev/null +++ b/source_text.lua @@ -0,0 +1,1561 @@ +-- text editor, particularly text drawing, horizontal wrap, vertical scrolling +Text = {} +AB_padding = 20 -- space in pixels between A side and B side + +-- draw a line starting from startpos to screen at y between State.left and State.right +-- return the final y, and pos,posB of start of final screen line drawn +function Text.draw(State, line_index, y, startpos, startposB) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + line_cache.starty = y + line_cache.startpos = startpos + line_cache.startposB = startposB + -- draw A side + local overflows_screen, x, pos, screen_line_starting_pos + if startpos then + overflows_screen, x, y, pos, screen_line_starting_pos = Text.draw_wrapping_line(State, line_index, State.left, y, startpos) + if overflows_screen then + return y, screen_line_starting_pos + end + if Focus == 'edit' and State.cursor1.pos then + if State.search_term == nil then + if line_index == State.cursor1.line and State.cursor1.pos == pos then + Text.draw_cursor(State, x, y) + end + end + end + else + x = State.left + end + -- check for B side +--? if line_index == 8 then print('checking for B side') end + if line.dataB == nil then + assert(y) + assert(screen_line_starting_pos) +--? if line_index == 8 then print('return 1') end + return y, screen_line_starting_pos + end + if not State.expanded and not line.expanded then + assert(y) + assert(screen_line_starting_pos) +--? if line_index == 8 then print('return 2') end + button(State, 'expand', {x=x+AB_padding, y=y+2, w=App.width(State.em), h=State.line_height-4, color={1,1,1}, + icon = function(button_params) + App.color(Fold_background_color) + love.graphics.rectangle('fill', button_params.x, button_params.y, App.width(State.em), State.line_height-4, 2,2) + end, + onpress1 = function() + line.expanded = true + end, + }) + return y, screen_line_starting_pos + end + -- draw B side +--? if line_index == 8 then print('drawing B side') end + App.color(Fold_color) +--? if Foo then +--? print('draw:', State.lines[line_index].data, "=====", State.lines[line_index].dataB, 'starting from x', x+AB_padding) +--? end + if startposB then + overflows_screen, x, y, pos, screen_line_starting_pos = Text.draw_wrapping_lineB(State, line_index, x,y, startposB) + else + overflows_screen, x, y, pos, screen_line_starting_pos = Text.draw_wrapping_lineB(State, line_index, x+AB_padding,y, 1) + end + if overflows_screen then + return y, nil, screen_line_starting_pos + end +--? if line_index == 8 then print('a') end + if Focus == 'edit' and State.cursor1.posB then +--? if line_index == 8 then print('b') end + if State.search_term == nil then +--? if line_index == 8 then print('c', State.cursor1.line, State.cursor1.posB, line_index, pos) end + if line_index == State.cursor1.line and State.cursor1.posB == pos then + Text.draw_cursor(State, x, y) + end + end + end + return y, nil, screen_line_starting_pos +end + +-- Given an array of fragments, draw the subset starting from pos to screen +-- starting from (x,y). +-- Return: +-- - whether we got to bottom of screen before end of line +-- - the final (x,y) +-- - the final pos +-- - starting pos of the final screen line drawn +function Text.draw_wrapping_line(State, line_index, x,y, startpos) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] +--? print('== line', line_index, '^'..line.data..'$') + local screen_line_starting_pos = startpos + Text.compute_fragments(State, line_index) + local pos = 1 + initialize_color() + for _, f in ipairs(line_cache.fragments) do + App.color(Text_color) + local frag, frag_text = f.data, f.text + select_color(frag) + local frag_len = utf8.len(frag) +--? print('text.draw:', frag, 'at', line_index,pos, 'after', x,y) + if pos < startpos then + -- render nothing +--? print('skipping', frag) + else + -- render fragment + local frag_width = App.width(frag_text) + if x + frag_width > State.right then + assert(x > State.left) -- no overfull lines + y = y + State.line_height + if y + State.line_height > App.screen.height then + return --[[screen filled]] true, x,y, pos, screen_line_starting_pos + end + screen_line_starting_pos = pos + x = State.left + end + App.screen.draw(frag_text, x,y) + -- render cursor if necessary + if State.cursor1.pos and line_index == State.cursor1.line then + if pos <= State.cursor1.pos and pos + frag_len > State.cursor1.pos then + if State.search_term then + if State.lines[State.cursor1.line].data:sub(State.cursor1.pos, State.cursor1.pos+utf8.len(State.search_term)-1) == State.search_term then + local lo_px = Text.draw_highlight(State, line, x,y, pos, State.cursor1.pos, State.cursor1.pos+utf8.len(State.search_term)) + App.color(Text_color) + love.graphics.print(State.search_term, x+lo_px,y) + end + elseif Focus == 'edit' then + Text.draw_cursor(State, x+Text.x(frag, State.cursor1.pos-pos+1), y) + App.color(Text_color) + end + end + end + x = x + frag_width + end + pos = pos + frag_len + end + return false, x,y, pos, screen_line_starting_pos +end + +function Text.draw_wrapping_lineB(State, line_index, x,y, startpos) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + local screen_line_starting_pos = startpos + Text.compute_fragmentsB(State, line_index, x) + local pos = 1 + for _, f in ipairs(line_cache.fragmentsB) do + local frag, frag_text = f.data, f.text + local frag_len = utf8.len(frag) +--? print('text.draw:', frag, 'at', line_index,pos, 'after', x,y) + if pos < startpos then + -- render nothing +--? print('skipping', frag) + else + -- render fragment + local frag_width = App.width(frag_text) + if x + frag_width > State.right then + assert(x > State.left) -- no overfull lines + y = y + State.line_height + if y + State.line_height > App.screen.height then + return --[[screen filled]] true, x,y, pos, screen_line_starting_pos + end + screen_line_starting_pos = pos + x = State.left + end + App.screen.draw(frag_text, x,y) + -- render cursor if necessary + if State.cursor1.posB and line_index == State.cursor1.line then + if pos <= State.cursor1.posB and pos + frag_len > State.cursor1.posB then + if State.search_term then + if State.lines[State.cursor1.line].dataB:sub(State.cursor1.posB, State.cursor1.posB+utf8.len(State.search_term)-1) == State.search_term then + local lo_px = Text.draw_highlight(State, line, x,y, pos, State.cursor1.posB, State.cursor1.posB+utf8.len(State.search_term)) + App.color(Fold_color) + love.graphics.print(State.search_term, x+lo_px,y) + end + elseif Focus == 'edit' then + Text.draw_cursor(State, x+Text.x(frag, State.cursor1.posB-pos+1), y) + App.color(Fold_color) + end + end + end + x = x + frag_width + end + pos = pos + frag_len + end + return false, x,y, pos, screen_line_starting_pos +end + +function Text.draw_cursor(State, 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,State.line_height) + end + State.cursor_x = x + State.cursor_y = y+State.line_height +end + +function Text.populate_screen_line_starting_pos(State, line_index) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + if line_cache.screen_line_starting_pos then + return + end + -- duplicate some logic from Text.draw + Text.compute_fragments(State, line_index) + line_cache.screen_line_starting_pos = {1} + local x = State.left + local pos = 1 + for _, f in ipairs(line_cache.fragments) do + local frag, frag_text = f.data, f.text + -- render fragment + local frag_width = App.width(frag_text) + if x + frag_width > State.right then + x = State.left + table.insert(line_cache.screen_line_starting_pos, pos) + end + x = x + frag_width + local frag_len = utf8.len(frag) + pos = pos + frag_len + end +end + +function Text.compute_fragments(State, line_index) +--? print('compute_fragments', line_index, 'between', State.left, State.right) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + if line_cache.fragments then + return + end + line_cache.fragments = {} + local x = State.left + -- try to wrap at word boundaries + for frag in line.data:gmatch('%S*%s*') do + local frag_text = App.newText(love.graphics.getFont(), frag) + local frag_width = App.width(frag_text) +--? print('x: '..tostring(x)..'; frag_width: '..tostring(frag_width)..'; '..tostring(State.right-x)..'px to go') + while x + frag_width > State.right do +--? print(('checking whether to split fragment ^%s$ of width %d when rendering from %d'):format(frag, frag_width, x)) + if (x-State.left) < 0.8 * (State.right-State.left) then +--? print('splitting') + -- long word; chop it at some letter + -- We're not going to reimplement TeX here. + local bpos = Text.nearest_pos_less_than(frag, State.right - x) +--? print('bpos', bpos) + if bpos == 0 then break end -- avoid infinite loop when window is too narrow + local boffset = Text.offset(frag, bpos+1) -- byte _after_ bpos +--? print('space for '..tostring(bpos)..' graphemes, '..tostring(boffset-1)..' bytes') + local frag1 = string.sub(frag, 1, boffset-1) + local frag1_text = App.newText(love.graphics.getFont(), frag1) + local frag1_width = App.width(frag1_text) +--? print('extracting ^'..frag1..'$ of width '..tostring(frag1_width)..'px') + assert(x + frag1_width <= State.right) + table.insert(line_cache.fragments, {data=frag1, text=frag1_text}) + frag = string.sub(frag, boffset) + frag_text = App.newText(love.graphics.getFont(), frag) + frag_width = App.width(frag_text) + end + x = State.left -- new line + end + if #frag > 0 then +--? print('inserting ^'..frag..'$ of width '..tostring(frag_width)..'px') + table.insert(line_cache.fragments, {data=frag, text=frag_text}) + end + x = x + frag_width + end +end + +function Text.populate_screen_line_starting_posB(State, line_index, x) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + if line_cache.screen_line_starting_posB then + return + end + -- duplicate some logic from Text.draw + Text.compute_fragmentsB(State, line_index, x) + line_cache.screen_line_starting_posB = {1} + local pos = 1 + for _, f in ipairs(line_cache.fragmentsB) do + local frag, frag_text = f.data, f.text + -- render fragment + local frag_width = App.width(frag_text) + if x + frag_width > State.right then + x = State.left + table.insert(line_cache.screen_line_starting_posB, pos) + end + x = x + frag_width + local frag_len = utf8.len(frag) + pos = pos + frag_len + end +end + +function Text.compute_fragmentsB(State, line_index, x) +--? print('compute_fragmentsB', line_index, 'between', x, State.right) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + if line_cache.fragmentsB then + return + end + line_cache.fragmentsB = {} + -- try to wrap at word boundaries + for frag in line.dataB:gmatch('%S*%s*') do + local frag_text = App.newText(love.graphics.getFont(), frag) + local frag_width = App.width(frag_text) +--? print('x: '..tostring(x)..'; '..tostring(State.right-x)..'px to go') + while x + frag_width > State.right do +--? print(('checking whether to split fragment ^%s$ of width %d when rendering from %d'):format(frag, frag_width, x)) + if (x-State.left) < 0.8 * (State.right-State.left) then +--? print('splitting') + -- long word; chop it at some letter + -- We're not going to reimplement TeX here. + local bpos = Text.nearest_pos_less_than(frag, State.right - x) +--? print('bpos', bpos) + if bpos == 0 then break end -- avoid infinite loop when window is too narrow + local boffset = Text.offset(frag, bpos+1) -- byte _after_ bpos +--? print('space for '..tostring(bpos)..' graphemes, '..tostring(boffset-1)..' bytes') + local frag1 = string.sub(frag, 1, boffset-1) + local frag1_text = App.newText(love.graphics.getFont(), frag1) + local frag1_width = App.width(frag1_text) +--? print('extracting ^'..frag1..'$ of width '..tostring(frag1_width)..'px') + assert(x + frag1_width <= State.right) + table.insert(line_cache.fragmentsB, {data=frag1, text=frag1_text}) + frag = string.sub(frag, boffset) + frag_text = App.newText(love.graphics.getFont(), frag) + frag_width = App.width(frag_text) + end + x = State.left -- new line + end + if #frag > 0 then +--? print('inserting ^'..frag..'$ of width '..tostring(frag_width)..'px') + table.insert(line_cache.fragmentsB, {data=frag, text=frag_text}) + end + x = x + frag_width + end +end + +function Text.textinput(State, t) + if App.mouse_down(1) then return end + if App.ctrl_down() or App.alt_down() or App.cmd_down() then return end + local before = snapshot(State, State.cursor1.line) +--? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos) + Text.insert_at_cursor(State, t) + if State.cursor_y > App.screen.height - State.line_height then + Text.populate_screen_line_starting_pos(State, State.cursor1.line) + Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right) + end + record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)}) +end + +function Text.insert_at_cursor(State, t) + if State.cursor1.pos then + local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos) + State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_offset-1)..t..string.sub(State.lines[State.cursor1.line].data, byte_offset) + Text.clear_screen_line_cache(State, State.cursor1.line) + State.cursor1.pos = State.cursor1.pos+1 + else + assert(State.cursor1.posB) + local byte_offset = Text.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB) + State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_offset-1)..t..string.sub(State.lines[State.cursor1.line].dataB, byte_offset) + Text.clear_screen_line_cache(State, State.cursor1.line) + State.cursor1.posB = State.cursor1.posB+1 + end +end + +-- Don't handle any keys here that would trigger love.textinput above. +function Text.keychord_pressed(State, chord) +--? print('chord', chord) + --== shortcuts that mutate text + if chord == 'return' then + local before_line = State.cursor1.line + local before = snapshot(State, before_line) + Text.insert_return(State) + if State.cursor_y > App.screen.height - State.line_height then + Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right) + end + schedule_save(State) + record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)}) + elseif chord == 'tab' then + local before = snapshot(State, State.cursor1.line) +--? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos) + Text.insert_at_cursor(State, '\t') + if State.cursor_y > App.screen.height - State.line_height then + Text.populate_screen_line_starting_pos(State, State.cursor1.line) + Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right) +--? print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos) + end + schedule_save(State) + record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)}) + elseif chord == 'backspace' then + local before + if State.cursor1.pos and State.cursor1.pos > 1 then + before = snapshot(State, State.cursor1.line) + local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos-1) + local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos) + if byte_start then + if byte_end then + State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].data, byte_end) + else + State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1) + end + State.cursor1.pos = State.cursor1.pos-1 + end + elseif State.cursor1.posB then + if State.cursor1.posB > 1 then + before = snapshot(State, State.cursor1.line) + local byte_start = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB-1) + local byte_end = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB) + if byte_start then + if byte_end then + State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].dataB, byte_end) + else + State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1) + end + State.cursor1.posB = State.cursor1.posB-1 + end + else + -- refuse to delete past beginning of side B + end + elseif State.cursor1.line > 1 then + before = snapshot(State, State.cursor1.line-1, State.cursor1.line) + -- join lines + State.cursor1.pos = utf8.len(State.lines[State.cursor1.line-1].data)+1 + State.lines[State.cursor1.line-1].data = State.lines[State.cursor1.line-1].data..State.lines[State.cursor1.line].data + table.remove(State.lines, State.cursor1.line) + table.remove(State.line_cache, State.cursor1.line) + State.cursor1.line = State.cursor1.line-1 + end + if State.screen_top1.line > #State.lines then + Text.populate_screen_line_starting_pos(State, #State.lines) + local line_cache = State.line_cache[#State.line_cache] + State.screen_top1 = {line=#State.lines, pos=line_cache.screen_line_starting_pos[#line_cache.screen_line_starting_pos]} + elseif Text.lt1(State.cursor1, State.screen_top1) then + local top2 = Text.to2(State, State.screen_top1) + top2 = Text.previous_screen_line(State, top2, State.left, State.right) + State.screen_top1 = Text.to1(State, top2) + Text.redraw_all(State) -- if we're scrolling, reclaim all fragments to avoid memory leaks + end + Text.clear_screen_line_cache(State, State.cursor1.line) + assert(Text.le1(State.screen_top1, State.cursor1)) + schedule_save(State) + record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)}) + elseif chord == 'delete' then + local before + if State.cursor1.posB or State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then + before = snapshot(State, State.cursor1.line) + else + before = snapshot(State, State.cursor1.line, State.cursor1.line+1) + end + if State.cursor1.pos and State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then + local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos) + local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos+1) + if byte_start then + if byte_end then + State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].data, byte_end) + else + State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1) + end + -- no change to State.cursor1.pos + end + elseif State.cursor1.posB then + if State.cursor1.posB <= utf8.len(State.lines[State.cursor1.line].dataB) then + local byte_start = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB) + local byte_end = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB+1) + if byte_start then + if byte_end then + State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].dataB, byte_end) + else + State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1) + end + -- no change to State.cursor1.pos + end + else + -- refuse to delete past end of side B + end + elseif State.cursor1.line < #State.lines then + -- join lines + State.lines[State.cursor1.line].data = State.lines[State.cursor1.line].data..State.lines[State.cursor1.line+1].data + -- delete side B on first line + State.lines[State.cursor1.line].dataB = State.lines[State.cursor1.line+1].dataB + table.remove(State.lines, State.cursor1.line+1) + table.remove(State.line_cache, State.cursor1.line+1) + end + Text.clear_screen_line_cache(State, State.cursor1.line) + schedule_save(State) + record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)}) + --== shortcuts that move the cursor + elseif chord == 'left' then + Text.left(State) + elseif chord == 'right' then + Text.right(State) + elseif chord == 'S-left' then + Text.left(State) + elseif chord == 'S-right' then + Text.right(State) + -- C- hotkeys reserved for drawings, so we'll use M- + elseif chord == 'M-left' then + Text.word_left(State) + elseif chord == 'M-right' then + Text.word_right(State) + elseif chord == 'M-S-left' then + Text.word_left(State) + elseif chord == 'M-S-right' then + Text.word_right(State) + elseif chord == 'home' then + Text.start_of_line(State) + elseif chord == 'end' then + Text.end_of_line(State) + elseif chord == 'S-home' then + Text.start_of_line(State) + elseif chord == 'S-end' then + Text.end_of_line(State) + elseif chord == 'up' then + Text.up(State) + elseif chord == 'down' then + Text.down(State) + elseif chord == 'S-up' then + Text.up(State) + elseif chord == 'S-down' then + Text.down(State) + elseif chord == 'pageup' then + Text.pageup(State) + elseif chord == 'pagedown' then + Text.pagedown(State) + elseif chord == 'S-pageup' then + Text.pageup(State) + elseif chord == 'S-pagedown' then + Text.pagedown(State) + end +end + +function Text.insert_return(State) + if State.cursor1.pos then + -- when inserting a newline, move any B side to the new line + local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos) + table.insert(State.lines, State.cursor1.line+1, {data=string.sub(State.lines[State.cursor1.line].data, byte_offset), dataB=State.lines[State.cursor1.line].dataB}) + table.insert(State.line_cache, State.cursor1.line+1, {}) + State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_offset-1) + State.lines[State.cursor1.line].dataB = nil + Text.clear_screen_line_cache(State, State.cursor1.line) + State.cursor1 = {line=State.cursor1.line+1, pos=1} + else + -- disable enter when cursor is on the B side + end +end + +function Text.pageup(State) +--? print('pageup') + -- duplicate some logic from love.draw + local top2 = Text.to2(State, State.screen_top1) +--? print(App.screen.height) + local y = App.screen.height - State.line_height + while y >= State.top do +--? print(y, top2.line, top2.screen_line, top2.screen_pos) + if State.screen_top1.line == 1 and State.screen_top1.pos and State.screen_top1.pos == 1 then break end + y = y - State.line_height + top2 = Text.previous_screen_line(State, top2) + end + State.screen_top1 = Text.to1(State, top2) + State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos, posB=State.screen_top1.posB} + Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State) +--? print(State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos) +--? print('pageup end') +end + +function Text.pagedown(State) +--? print('pagedown') + local bot2 = Text.to2(State, State.screen_bottom1) + local new_top1 = Text.to1(State, bot2) + if Text.lt1(State.screen_top1, new_top1) then + State.screen_top1 = new_top1 + else + State.screen_top1 = {line=State.screen_bottom1.line, pos=State.screen_bottom1.pos} + end +--? print('setting top to', State.screen_top1.line, State.screen_top1.pos) + State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos, posB=State.screen_top1.posB} + Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State) +--? print('top now', State.screen_top1.line) + Text.redraw_all(State) -- if we're scrolling, reclaim all fragments to avoid memory leaks +--? print('pagedown end') +end + +function Text.up(State) + if State.cursor1.pos then + Text.upA(State) + else + Text.upB(State) + end +end + +function Text.upA(State) +--? print('up', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos) + local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1) + if screen_line_starting_pos == 1 then +--? print('cursor is at first screen line of its line') + -- line is done; skip to previous text line + if State.cursor1.line > 1 then +--? print('found previous text line') + State.cursor1 = {line=State.cursor1.line-1, pos=nil} + Text.populate_screen_line_starting_pos(State, State.cursor1.line) + -- previous text line found, pick its final screen line +--? print('has multiple screen lines') + local screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos +--? print(#screen_line_starting_pos) + screen_line_starting_pos = screen_line_starting_pos[#screen_line_starting_pos] + local screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, screen_line_starting_pos) + local s = string.sub(State.lines[State.cursor1.line].data, screen_line_starting_byte_offset) + State.cursor1.pos = screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1 + end + else + -- move up one screen line in current line + assert(screen_line_index > 1) + local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index-1] + local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos) + local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset) + State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1 +--? print('cursor pos is now '..tostring(State.cursor1.pos)) + end + if Text.lt1(State.cursor1, State.screen_top1) then + local top2 = Text.to2(State, State.screen_top1) + top2 = Text.previous_screen_line(State, top2) + State.screen_top1 = Text.to1(State, top2) + end +end + +function Text.upB(State) + local line_cache = State.line_cache[State.cursor1.line] + local screen_line_starting_posB, screen_line_indexB = Text.pos_at_start_of_screen_lineB(State, State.cursor1) + assert(screen_line_indexB >= 1) + if screen_line_indexB == 1 then + -- move to A side of previous line + if State.cursor1.line > 1 then + State.cursor1.line = State.cursor1.line-1 + State.cursor1.posB = nil + Text.populate_screen_line_starting_pos(State, State.cursor1.line) + local prev_line_cache = State.line_cache[State.cursor1.line] + local prev_screen_line_starting_pos = prev_line_cache.screen_line_starting_pos[#prev_line_cache.screen_line_starting_pos] + local prev_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, prev_screen_line_starting_pos) + local s = string.sub(State.lines[State.cursor1.line].data, prev_screen_line_starting_byte_offset) + State.cursor1.pos = prev_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1 + end + elseif screen_line_indexB == 2 then + -- all-B screen-line to potentially A+B screen-line + local xA = Margin_left + Text.screen_line_width(State, State.cursor1.line, #line_cache.screen_line_starting_pos) + AB_padding + if State.cursor_x < xA then + State.cursor1.posB = nil + Text.populate_screen_line_starting_pos(State, State.cursor1.line) + local new_screen_line_starting_pos = line_cache.screen_line_starting_pos[#line_cache.screen_line_starting_pos] + local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos) + local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset) + State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1 + else + Text.populate_screen_line_starting_posB(State, State.cursor1.line) + local new_screen_line_starting_posB = line_cache.screen_line_starting_posB[screen_line_indexB-1] + local new_screen_line_starting_byte_offsetB = Text.offset(State.lines[State.cursor1.line].dataB, new_screen_line_starting_posB) + local s = string.sub(State.lines[State.cursor1.line].dataB, new_screen_line_starting_byte_offsetB) + State.cursor1.posB = new_screen_line_starting_posB + Text.nearest_cursor_pos(s, State.cursor_x-xA, State.left) - 1 + end + else + assert(screen_line_indexB > 2) + -- all-B screen-line to all-B screen-line + Text.populate_screen_line_starting_posB(State, State.cursor1.line) + local new_screen_line_starting_posB = line_cache.screen_line_starting_posB[screen_line_indexB-1] + local new_screen_line_starting_byte_offsetB = Text.offset(State.lines[State.cursor1.line].dataB, new_screen_line_starting_posB) + local s = string.sub(State.lines[State.cursor1.line].dataB, new_screen_line_starting_byte_offsetB) + State.cursor1.posB = new_screen_line_starting_posB + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1 + end + if Text.lt1(State.cursor1, State.screen_top1) then + local top2 = Text.to2(State, State.screen_top1) + top2 = Text.previous_screen_line(State, top2) + State.screen_top1 = Text.to1(State, top2) + end +end + +-- cursor on final screen line (A or B side) => goes to next screen line on A side +-- cursor on A side => move down one screen line (A side) in current line +-- cursor on B side => move down one screen line (B side) in current line +function Text.down(State) +--? print('down', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos) + if Text.cursor_at_final_screen_line(State) then + -- line is done, skip to next text line +--? print('cursor at final screen line of its line') + if State.cursor1.line < #State.lines then + State.cursor1 = { + line = State.cursor1.line+1, + pos = Text.nearest_cursor_pos(State.lines[State.cursor1.line+1].data, State.cursor_x, State.left) + } +--? print(State.cursor1.pos) + end + if State.cursor1.line > State.screen_bottom1.line then +--? print('screen top before:', State.screen_top1.line, State.screen_top1.pos) +--? print('scroll up preserving cursor') + Text.snap_cursor_to_bottom_of_screen(State) +--? print('screen top after:', State.screen_top1.line, State.screen_top1.pos) + end + elseif State.cursor1.pos then + -- move down one screen line (A side) in current line + local scroll_down = Text.le1(State.screen_bottom1, State.cursor1) +--? print('cursor is NOT at final screen line of its line') + local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1) + Text.populate_screen_line_starting_pos(State, State.cursor1.line) + local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index+1] +--? print('switching pos of screen line at cursor from '..tostring(screen_line_starting_pos)..' to '..tostring(new_screen_line_starting_pos)) + local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos) + local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset) + State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1 +--? print('cursor pos is now', State.cursor1.line, State.cursor1.pos) + if scroll_down then +--? print('scroll up preserving cursor') + Text.snap_cursor_to_bottom_of_screen(State) +--? print('screen top after:', State.screen_top1.line, State.screen_top1.pos) + end + else + -- move down one screen line (B side) in current line + local scroll_down = false + if Text.le1(State.screen_bottom1, State.cursor1) then + scroll_down = true + end + local cursor_line = State.lines[State.cursor1.line] + local cursor_line_cache = State.line_cache[State.cursor1.line] + local cursor2 = Text.to2(State, State.cursor1) + assert(cursor2.screen_lineB < #cursor_line_cache.screen_line_starting_posB) + local screen_line_starting_posB, screen_line_indexB = Text.pos_at_start_of_screen_lineB(State, State.cursor1) + Text.populate_screen_line_starting_posB(State, State.cursor1.line) + local new_screen_line_starting_posB = cursor_line_cache.screen_line_starting_posB[screen_line_indexB+1] + local new_screen_line_starting_byte_offsetB = Text.offset(cursor_line.dataB, new_screen_line_starting_posB) + local s = string.sub(cursor_line.dataB, new_screen_line_starting_byte_offsetB) + State.cursor1.posB = new_screen_line_starting_posB + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1 + if scroll_down then + Text.snap_cursor_to_bottom_of_screen(State) + end + end +--? print('=>', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos) +end + +function Text.start_of_line(State) + if State.cursor1.pos then + State.cursor1.pos = 1 + else + State.cursor1.posB = 1 + end + if Text.lt1(State.cursor1, State.screen_top1) then + State.screen_top1 = {line=State.cursor1.line, pos=1} -- copy + end +end + +function Text.end_of_line(State) + if State.cursor1.pos then + State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + 1 + else + State.cursor1.posB = utf8.len(State.lines[State.cursor1.line].dataB) + 1 + end + if Text.cursor_out_of_screen(State) then + Text.snap_cursor_to_bottom_of_screen(State) + end +end + +function Text.word_left(State) + -- we can cross the fold, so check side A/B one level down + Text.skip_whitespace_left(State) + Text.left(State) + Text.skip_non_whitespace_left(State) +end + +function Text.word_right(State) + -- we can cross the fold, so check side A/B one level down + Text.skip_whitespace_right(State) + Text.right(State) + Text.skip_non_whitespace_right(State) + if Text.cursor_out_of_screen(State) then + Text.snap_cursor_to_bottom_of_screen(State) + end +end + +function Text.skip_whitespace_left(State) + if State.cursor1.pos then + Text.skip_whitespace_leftA(State) + else + Text.skip_whitespace_leftB(State) + end +end + +function Text.skip_non_whitespace_left(State) + if State.cursor1.pos then + Text.skip_non_whitespace_leftA(State) + else + Text.skip_non_whitespace_leftB(State) + end +end + +function Text.skip_whitespace_leftA(State) + while true do + if State.cursor1.pos == 1 then + break + end + if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%S') then + break + end + Text.left(State) + end +end + +function Text.skip_whitespace_leftB(State) + while true do + if State.cursor1.posB == 1 then + break + end + if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB-1, '%S') then + break + end + Text.left(State) + end +end + +function Text.skip_non_whitespace_leftA(State) + while true do + if State.cursor1.pos == 1 then + break + end + assert(State.cursor1.pos > 1) + if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%s') then + break + end + Text.left(State) + end +end + +function Text.skip_non_whitespace_leftB(State) + while true do + if State.cursor1.posB == 1 then + break + end + assert(State.cursor1.posB > 1) + if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB-1, '%s') then + break + end + Text.left(State) + end +end + +function Text.skip_whitespace_right(State) + if State.cursor1.pos then + Text.skip_whitespace_rightA(State) + else + Text.skip_whitespace_rightB(State) + end +end + +function Text.skip_non_whitespace_right(State) + if State.cursor1.pos then + Text.skip_non_whitespace_rightA(State) + else + Text.skip_non_whitespace_rightB(State) + end +end + +function Text.skip_whitespace_rightA(State) + while true do + if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then + break + end + if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%S') then + break + end + Text.right_without_scroll(State) + end +end + +function Text.skip_whitespace_rightB(State) + while true do + if State.cursor1.posB > utf8.len(State.lines[State.cursor1.line].dataB) then + break + end + if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB, '%S') then + break + end + Text.right_without_scroll(State) + end +end + +function Text.skip_non_whitespace_rightA(State) + while true do + if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then + break + end + if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%s') then + break + end + Text.right_without_scroll(State) + end +end + +function Text.skip_non_whitespace_rightB(State) + while true do + if State.cursor1.posB > utf8.len(State.lines[State.cursor1.line].dataB) then + break + end + if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB, '%s') then + break + end + Text.right_without_scroll(State) + end +end + +function Text.match(s, pos, pat) + local start_offset = Text.offset(s, pos) + assert(start_offset) + local end_offset = Text.offset(s, pos+1) + assert(end_offset > start_offset) + local curr = s:sub(start_offset, end_offset-1) + return curr:match(pat) +end + +function Text.left(State) + if State.cursor1.pos then + Text.leftA(State) + else + Text.leftB(State) + end +end + +function Text.leftA(State) + if State.cursor1.pos > 1 then + State.cursor1.pos = State.cursor1.pos-1 + elseif State.cursor1.line > 1 then + State.cursor1 = { + line = State.cursor1.line-1, + pos = utf8.len(State.lines[State.cursor1.line-1].data) + 1, + } + end + if Text.lt1(State.cursor1, State.screen_top1) then + local top2 = Text.to2(State, State.screen_top1) + top2 = Text.previous_screen_line(State, top2) + State.screen_top1 = Text.to1(State, top2) + end +end + +function Text.leftB(State) + if State.cursor1.posB > 1 then + State.cursor1.posB = State.cursor1.posB-1 + else + -- overflow back into A side + State.cursor1.posB = nil + State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + 1 + end + if Text.lt1(State.cursor1, State.screen_top1) then + local top2 = Text.to2(State, State.screen_top1) + top2 = Text.previous_screen_line(State, top2) + State.screen_top1 = Text.to1(State, top2) + end +end + +function Text.right(State) + Text.right_without_scroll(State) + if Text.cursor_out_of_screen(State) then + Text.snap_cursor_to_bottom_of_screen(State) + end +end + +function Text.right_without_scroll(State) + if State.cursor1.pos then + Text.right_without_scrollA(State) + else + Text.right_without_scrollB(State) + end +end + +function Text.right_without_scrollA(State) + if State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then + State.cursor1.pos = State.cursor1.pos+1 + elseif State.cursor1.line <= #State.lines-1 then + State.cursor1 = {line=State.cursor1.line+1, pos=1} + end +end + +function Text.right_without_scrollB(State) + if State.cursor1.posB <= utf8.len(State.lines[State.cursor1.line].dataB) then + State.cursor1.posB = State.cursor1.posB+1 + elseif State.cursor1.line <= #State.lines-1 then + -- overflow back into A side + State.cursor1 = {line=State.cursor1.line+1, pos=1} + end +end + +function Text.pos_at_start_of_screen_line(State, loc1) + Text.populate_screen_line_starting_pos(State, loc1.line) + local line_cache = State.line_cache[loc1.line] + for i=#line_cache.screen_line_starting_pos,1,-1 do + local spos = line_cache.screen_line_starting_pos[i] + if spos <= loc1.pos then + return spos,i + end + end + assert(false) +end + +function Text.pos_at_start_of_screen_lineB(State, loc1) + Text.populate_screen_line_starting_pos(State, loc1.line) + local line_cache = State.line_cache[loc1.line] + local x = Margin_left + Text.screen_line_width(State, loc1.line, #line_cache.screen_line_starting_pos) + AB_padding + Text.populate_screen_line_starting_posB(State, loc1.line, x) + for i=#line_cache.screen_line_starting_posB,1,-1 do + local sposB = line_cache.screen_line_starting_posB[i] + if sposB <= loc1.posB then + return sposB,i + end + end + assert(false) +end + +function Text.cursor_at_final_screen_line(State) + Text.populate_screen_line_starting_pos(State, State.cursor1.line) + local line = State.lines[State.cursor1.line] + local screen_lines = State.line_cache[State.cursor1.line].screen_line_starting_pos +--? print(screen_lines[#screen_lines], State.cursor1.pos) + if (not State.expanded and not line.expanded) or + line.dataB == nil then + return screen_lines[#screen_lines] <= State.cursor1.pos + end + if State.cursor1.pos then + -- ignore B side + return screen_lines[#screen_lines] <= State.cursor1.pos + end + assert(State.cursor1.posB) + local line_cache = State.line_cache[State.cursor1.line] + local x = Margin_left + Text.screen_line_width(State, State.cursor1.line, #line_cache.screen_line_starting_pos) + AB_padding + Text.populate_screen_line_starting_posB(State, State.cursor1.line, x) + local screen_lines = State.line_cache[State.cursor1.line].screen_line_starting_posB + return screen_lines[#screen_lines] <= State.cursor1.posB +end + +function Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State) + if State.top > App.screen.height - State.line_height then +--? print('scroll up') + Text.snap_cursor_to_bottom_of_screen(State) + end +end + +-- should never modify State.cursor1 +function Text.snap_cursor_to_bottom_of_screen(State) +--? print('to2:', State.cursor1.line, State.cursor1.pos, State.cursor1.posB) + local top2 = Text.to2(State, State.cursor1) +--? print('to2: =>', top2.line, top2.screen_line, top2.screen_pos, top2.screen_lineB, top2.screen_posB) + -- slide to start of screen line + if top2.screen_pos then + top2.screen_pos = 1 + else + assert(top2.screen_posB) + top2.screen_posB = 1 + end +--? print('snap', State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.cursor1.posB, State.screen_bottom1.line, State.screen_bottom1.pos, State.screen_bottom1.posB) +--? print('cursor pos '..tostring(State.cursor1.pos)..' is on the #'..tostring(top2.screen_line)..' screen line down') + local y = App.screen.height - State.line_height + -- duplicate some logic from love.draw + while true do +--? print(y, 'top2:', State.lines[top2.line].data, top2.line, top2.screen_line, top2.screen_pos, top2.screen_lineB, top2.screen_posB) + if top2.line == 1 and top2.screen_line == 1 then break end + local h = State.line_height + if y - h < State.top then + break + end + y = y - h + top2 = Text.previous_screen_line(State, top2) + end +--? print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos) + State.screen_top1 = Text.to1(State, top2) +--? print('top1 finally:', State.screen_top1.line, State.screen_top1.pos) +--? print('snap =>', State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.cursor1.posB, State.screen_bottom1.line, State.screen_bottom1.pos, State.screen_bottom1.posB) + Text.redraw_all(State) -- if we're scrolling, reclaim all fragments to avoid memory leaks + Foo = true +end + +function Text.in_line(State, line_index, x,y) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + if line_cache.starty == nil then return false end -- outside current page + if y < line_cache.starty then return false end + local num_screen_lines = 0 + if line_cache.startpos then + Text.populate_screen_line_starting_pos(State, line_index) + num_screen_lines = num_screen_lines + #line_cache.screen_line_starting_pos - Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos) + 1 + end +--? print('#screenlines after A', num_screen_lines) + if line.dataB and (State.expanded or line.expanded) then + local x = Margin_left + Text.screen_line_width(State, line_index, #line_cache.screen_line_starting_pos) + AB_padding + Text.populate_screen_line_starting_posB(State, line_index, x) +--? print('B:', x, #line_cache.screen_line_starting_posB) + if line_cache.startposB then + num_screen_lines = num_screen_lines + #line_cache.screen_line_starting_posB - Text.screen_line_indexB(line_cache.screen_line_starting_posB, line_cache.startposB) -- no +1; first screen line of B side overlaps with A side + else + num_screen_lines = num_screen_lines + #line_cache.screen_line_starting_posB - Text.screen_line_indexB(line_cache.screen_line_starting_posB, 1) -- no +1; first screen line of B side overlaps with A side + end + end +--? print('#screenlines after B', num_screen_lines) + return y < line_cache.starty + State.line_height*num_screen_lines +end + +-- convert mx,my in pixels to schema-1 coordinates +-- returns: pos, posB +-- scenarios: +-- line without B side +-- line with B side collapsed +-- line with B side expanded +-- line starting rendering in A side (startpos ~= nil) +-- line starting rendering in B side (startposB ~= nil) +-- my on final screen line of A side +-- mx to right of A side with no B side +-- mx to right of A side but left of B side +-- mx to right of B side +-- preconditions: +-- startpos xor startposB +-- expanded -> dataB +function Text.to_pos_on_line(State, line_index, mx, my) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + assert(my >= line_cache.starty) + -- duplicate some logic from Text.draw + local y = line_cache.starty +--? print('click', line_index, my, 'with line starting at', y, #line_cache.screen_line_starting_pos) -- , #line_cache.screen_line_starting_posB) + if line_cache.startpos then + local start_screen_line_index = Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos) + for screen_line_index = start_screen_line_index,#line_cache.screen_line_starting_pos do + local screen_line_starting_pos = line_cache.screen_line_starting_pos[screen_line_index] + local screen_line_starting_byte_offset = Text.offset(line.data, screen_line_starting_pos) +--? print('iter', y, screen_line_index, screen_line_starting_pos, string.sub(line.data, screen_line_starting_byte_offset)) + local nexty = y + State.line_height + if my < nexty then + -- On all wrapped screen lines but the final one, clicks past end of + -- line position cursor on final character of screen line. + -- (The final screen line positions past end of screen line as always.) + if screen_line_index < #line_cache.screen_line_starting_pos and mx > State.left + Text.screen_line_width(State, line_index, screen_line_index) then +--? print('past end of non-final line; return') + return line_cache.screen_line_starting_pos[screen_line_index+1]-1 + end + local s = string.sub(line.data, screen_line_starting_byte_offset) +--? print('return', mx, Text.nearest_cursor_pos(s, mx, State.left), '=>', screen_line_starting_pos + Text.nearest_cursor_pos(s, mx, State.left) - 1) + local screen_line_posA = Text.nearest_cursor_pos(s, mx, State.left) + if line.dataB == nil then + -- no B side + return screen_line_starting_pos + screen_line_posA - 1 + end + if not State.expanded and not line.expanded then + -- B side is not expanded + return screen_line_starting_pos + screen_line_posA - 1 + end + local lenA = utf8.len(s) + if screen_line_posA < lenA then + -- mx is within A side + return screen_line_starting_pos + screen_line_posA - 1 + end + local max_xA = State.left+Text.x(s, lenA+1) + if mx < max_xA + AB_padding then + -- mx is in the space between A and B side + return screen_line_starting_pos + screen_line_posA - 1 + end + mx = mx - max_xA - AB_padding + local screen_line_posB = Text.nearest_cursor_pos(line.dataB, mx, --[[no left margin]] 0) + return nil, screen_line_posB + end + y = nexty + end + end + -- look in screen lines composed entirely of the B side + assert(State.expanded or line.expanded) + local start_screen_line_indexB + if line_cache.startposB then + start_screen_line_indexB = Text.screen_line_indexB(line_cache.screen_line_starting_posB, line_cache.startposB) + else + start_screen_line_indexB = 2 -- skip the first line of side B, which we checked above + end + for screen_line_indexB = start_screen_line_indexB,#line_cache.screen_line_starting_posB do + local screen_line_starting_posB = line_cache.screen_line_starting_posB[screen_line_indexB] + local screen_line_starting_byte_offsetB = Text.offset(line.dataB, screen_line_starting_posB) +--? print('iter2', y, screen_line_indexB, screen_line_starting_posB, string.sub(line.dataB, screen_line_starting_byte_offsetB)) + local nexty = y + State.line_height + if my < nexty then + -- On all wrapped screen lines but the final one, clicks past end of + -- line position cursor on final character of screen line. + -- (The final screen line positions past end of screen line as always.) +--? print('aa', mx, State.left, Text.screen_line_widthB(State, line_index, screen_line_indexB)) + if screen_line_indexB < #line_cache.screen_line_starting_posB and mx > State.left + Text.screen_line_widthB(State, line_index, screen_line_indexB) then +--? print('past end of non-final line; return') + return nil, line_cache.screen_line_starting_posB[screen_line_indexB+1]-1 + end + local s = string.sub(line.dataB, screen_line_starting_byte_offsetB) +--? print('return', mx, Text.nearest_cursor_pos(s, mx, State.left), '=>', screen_line_starting_posB + Text.nearest_cursor_pos(s, mx, State.left) - 1) + return nil, screen_line_starting_posB + Text.nearest_cursor_pos(s, mx, State.left) - 1 + end + y = nexty + end + assert(false) +end + +function Text.screen_line_width(State, line_index, i) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + local start_pos = line_cache.screen_line_starting_pos[i] + local start_offset = Text.offset(line.data, start_pos) + local screen_line + if i < #line_cache.screen_line_starting_pos then + local past_end_pos = line_cache.screen_line_starting_pos[i+1] + local past_end_offset = Text.offset(line.data, past_end_pos) + screen_line = string.sub(line.data, start_offset, past_end_offset-1) + else + screen_line = string.sub(line.data, start_pos) + end + local screen_line_text = App.newText(love.graphics.getFont(), screen_line) + return App.width(screen_line_text) +end + +function Text.screen_line_widthB(State, line_index, i) + local line = State.lines[line_index] + local line_cache = State.line_cache[line_index] + local start_posB = line_cache.screen_line_starting_posB[i] + local start_offsetB = Text.offset(line.dataB, start_posB) + local screen_line + if i < #line_cache.screen_line_starting_posB then +--? print('non-final', i) + local past_end_posB = line_cache.screen_line_starting_posB[i+1] + local past_end_offsetB = Text.offset(line.dataB, past_end_posB) +--? print('between', start_offsetB, past_end_offsetB) + screen_line = string.sub(line.dataB, start_offsetB, past_end_offsetB-1) + else +--? print('final', i) +--? print('after', start_offsetB) + screen_line = string.sub(line.dataB, start_offsetB) + end + local screen_line_text = App.newText(love.graphics.getFont(), screen_line) +--? local result = App.width(screen_line_text) +--? print('=>', result) +--? return result + return App.width(screen_line_text) +end + +function Text.screen_line_index(screen_line_starting_pos, pos) + for i = #screen_line_starting_pos,1,-1 do + if screen_line_starting_pos[i] <= pos then + return i + end + end +end + +function Text.screen_line_indexB(screen_line_starting_posB, posB) + if posB == nil then + return 0 + end + assert(screen_line_starting_posB) + for i = #screen_line_starting_posB,1,-1 do + if screen_line_starting_posB[i] <= posB then + return i + end + end +end + +-- convert x pixel coordinate to pos +-- oblivious to wrapping +-- result: 1 to len+1 +function Text.nearest_cursor_pos(line, x, left) + if x < left then + return 1 + end + local len = utf8.len(line) + local max_x = left+Text.x(line, len+1) + if x > max_x then + return len+1 + end + local leftpos, rightpos = 1, len+1 +--? print('-- nearest', x) + while true do +--? print('nearest', x, '^'..line..'$', leftpos, rightpos) + if leftpos == rightpos then + return leftpos + end + local curr = math.floor((leftpos+rightpos)/2) + local currxmin = left+Text.x(line, curr) + local currxmax = left+Text.x(line, curr+1) +--? print('nearest', x, leftpos, rightpos, curr, currxmin, currxmax) + if currxmin <= x and x < currxmax then + if x-currxmin < currxmax-x then + return curr + else + return curr+1 + end + end + if leftpos >= rightpos-1 then + return rightpos + end + if currxmin > x then + rightpos = curr + else + leftpos = curr + end + end + assert(false) +end + +-- return the nearest index of line (in utf8 code points) which lies entirely +-- within x pixels of the left margin +-- result: 0 to len+1 +function Text.nearest_pos_less_than(line, x) +--? print('', '-- nearest_pos_less_than', line, x) + local len = utf8.len(line) + local max_x = Text.x_after(line, len) + if x > max_x then + return len+1 + end + local left, right = 0, len+1 + while true do + local curr = math.floor((left+right)/2) + local currxmin = Text.x_after(line, curr+1) + local currxmax = Text.x_after(line, curr+2) +--? print('', x, left, right, curr, currxmin, currxmax) + if currxmin <= x and x < currxmax then + return curr + end + if left >= right-1 then + return left + end + if currxmin > x then + right = curr + else + left = curr + end + end + assert(false) +end + +function Text.x_after(s, pos) + local offset = Text.offset(s, math.min(pos+1, #s+1)) + local s_before = s:sub(1, offset-1) +--? print('^'..s_before..'$') + local text_before = App.newText(love.graphics.getFont(), s_before) + return App.width(text_before) +end + +function Text.x(s, pos) + local offset = Text.offset(s, pos) + local s_before = s:sub(1, offset-1) + local text_before = App.newText(love.graphics.getFont(), s_before) + return App.width(text_before) +end + +function Text.to2(State, loc1) + if loc1.pos then + return Text.to2A(State, loc1) + else + return Text.to2B(State, loc1) + end +end + +function Text.to2A(State, loc1) + local result = {line=loc1.line} + local line_cache = State.line_cache[loc1.line] + Text.populate_screen_line_starting_pos(State, loc1.line) + for i=#line_cache.screen_line_starting_pos,1,-1 do + local spos = line_cache.screen_line_starting_pos[i] + if spos <= loc1.pos then + result.screen_line = i + result.screen_pos = loc1.pos - spos + 1 + break + end + end + assert(result.screen_pos) + return result +end + +function Text.to2B(State, loc1) + local result = {line=loc1.line} + local line_cache = State.line_cache[loc1.line] + Text.populate_screen_line_starting_pos(State, loc1.line) + local x = Margin_left + Text.screen_line_width(State, loc1.line, #line_cache.screen_line_starting_pos) + AB_padding + Text.populate_screen_line_starting_posB(State, loc1.line, x) + for i=#line_cache.screen_line_starting_posB,1,-1 do + local sposB = line_cache.screen_line_starting_posB[i] + if sposB <= loc1.posB then + result.screen_lineB = i + result.screen_posB = loc1.posB - sposB + 1 + break + end + end + assert(result.screen_posB) + return result +end + +function Text.to1(State, loc2) + if loc2.screen_pos then + return Text.to1A(State, loc2) + else + return Text.to1B(State, loc2) + end +end + +function Text.to1A(State, loc2) + local result = {line=loc2.line, pos=loc2.screen_pos} + if loc2.screen_line > 1 then + result.pos = State.line_cache[loc2.line].screen_line_starting_pos[loc2.screen_line] + loc2.screen_pos - 1 + end + return result +end + +function Text.to1B(State, loc2) + local result = {line=loc2.line, posB=loc2.screen_posB} + if loc2.screen_lineB > 1 then + result.posB = State.line_cache[loc2.line].screen_line_starting_posB[loc2.screen_lineB] + loc2.screen_posB - 1 + end + return result +end + +function Text.lt1(a, b) + if a.line < b.line then + return true + end + if a.line > b.line then + return false + end + -- A side < B side + if a.pos and not b.pos then + return true + end + if not a.pos and b.pos then + return false + end + if a.pos then + return a.pos < b.pos + else + return a.posB < b.posB + end +end + +function Text.le1(a, b) + return eq(a, b) or Text.lt1(a, b) +end + +function Text.offset(s, pos1) + if pos1 == 1 then return 1 end + local result = utf8.offset(s, pos1) + if result == nil then + print(pos1, #s, s) + end + assert(result) + return result +end + +function Text.previous_screen_line(State, loc2) + if loc2.screen_pos then + return Text.previous_screen_lineA(State, loc2) + else + return Text.previous_screen_lineB(State, loc2) + end +end + +function Text.previous_screen_lineA(State, loc2) + if loc2.screen_line > 1 then +--? print('a') + return {line=loc2.line, screen_line=loc2.screen_line-1, screen_pos=1} + elseif loc2.line == 1 then +--? print('b') + return loc2 + else + Text.populate_screen_line_starting_pos(State, loc2.line-1) + if State.lines[loc2.line-1].dataB == nil or + (not State.expanded and not State.lines[loc2.line-1].expanded) then +--? print('c1', loc2.line-1, State.lines[loc2.line-1].data, '==', State.lines[loc2.line-1].dataB, State.line_cache[loc2.line-1].fragmentsB) + return {line=loc2.line-1, screen_line=#State.line_cache[loc2.line-1].screen_line_starting_pos, screen_pos=1} + end + -- try to switch to B + local prev_line_cache = State.line_cache[loc2.line-1] + local x = Margin_left + Text.screen_line_width(State, loc2.line-1, #prev_line_cache.screen_line_starting_pos) + AB_padding + Text.populate_screen_line_starting_posB(State, loc2.line-1, x) + local screen_line_starting_posB = State.line_cache[loc2.line-1].screen_line_starting_posB +--? print('c', loc2.line-1, State.lines[loc2.line-1].data, '==', State.lines[loc2.line-1].dataB, '==', #screen_line_starting_posB, 'starting from x', x) + if #screen_line_starting_posB > 1 then +--? print('c2') + return {line=loc2.line-1, screen_lineB=#State.line_cache[loc2.line-1].screen_line_starting_posB, screen_posB=1} + else +--? print('c3') + -- if there's only one screen line, assume it overlaps with A, so remain in A + return {line=loc2.line-1, screen_line=#State.line_cache[loc2.line-1].screen_line_starting_pos, screen_pos=1} + end + end +end + +function Text.previous_screen_lineB(State, loc2) + if loc2.screen_lineB > 2 then -- first screen line of B side overlaps with A side + return {line=loc2.line, screen_lineB=loc2.screen_lineB-1, screen_posB=1} + else + -- switch to A side + -- TODO: handle case where fold lands precisely at end of a new screen-line + return {line=loc2.line, screen_line=#State.line_cache[loc2.line].screen_line_starting_pos, screen_pos=1} + end +end + +-- resize helper +function Text.tweak_screen_top_and_cursor(State) + if State.screen_top1.pos == 1 then return end + Text.populate_screen_line_starting_pos(State, State.screen_top1.line) + local line = State.lines[State.screen_top1.line] + local line_cache = State.line_cache[State.screen_top1.line] + for i=2,#line_cache.screen_line_starting_pos do + local pos = line_cache.screen_line_starting_pos[i] + if pos == State.screen_top1.pos then + break + end + if pos > State.screen_top1.pos then + -- make sure screen top is at start of a screen line + local prev = line_cache.screen_line_starting_pos[i-1] + if State.screen_top1.pos - prev < pos - State.screen_top1.pos then + State.screen_top1.pos = prev + else + State.screen_top1.pos = pos + end + break + end + end + -- make sure cursor is on screen + if Text.lt1(State.cursor1, State.screen_top1) then + State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos} + elseif State.cursor1.line >= State.screen_bottom1.line then +--? print('too low') + if Text.cursor_out_of_screen(State) then +--? print('tweak') + local pos,posB = Text.to_pos_on_line(State, State.screen_bottom1.line, State.right-5, App.screen.height-5) + State.cursor1 = {line=State.screen_bottom1.line, pos=pos, posB=posB} + end + end +end + +-- slightly expensive since it redraws the screen +function Text.cursor_out_of_screen(State) + App.draw() + return State.cursor_y == nil + -- this approach is cheaper and almost works, except on the final screen + -- where file ends above bottom of screen +--? local botpos = Text.pos_at_start_of_screen_line(State, State.cursor1) +--? local botline1 = {line=State.cursor1.line, pos=botpos} +--? return Text.lt1(State.screen_bottom1, botline1) +end + +function Text.redraw_all(State) +--? print('clearing fragments') + State.line_cache = {} + for i=1,#State.lines do + State.line_cache[i] = {} + end +end + +function Text.clear_screen_line_cache(State, line_index) + State.line_cache[line_index].fragments = nil + State.line_cache[line_index].fragmentsB = nil + State.line_cache[line_index].screen_line_starting_pos = nil + State.line_cache[line_index].screen_line_starting_posB = nil +end + +function trim(s) + return s:gsub('^%s+', ''):gsub('%s+$', '') +end + +function ltrim(s) + return s:gsub('^%s+', '') +end + +function rtrim(s) + return s:gsub('%s+$', '') +end diff --git a/source_text_tests.lua b/source_text_tests.lua new file mode 100644 index 0000000..ecffb13 --- /dev/null +++ b/source_text_tests.lua @@ -0,0 +1,1609 @@ +-- major tests for text editing flows + +function test_initial_state() + io.write('\ntest_initial_state') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{} + Text.redraw_all(Editor_state) + edit.draw(Editor_state) + check_eq(#Editor_state.lines, 1, 'F - test_initial_state/#lines') + check_eq(Editor_state.cursor1.line, 1, 'F - test_initial_state/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_initial_state/cursor:pos') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_initial_state/screen_top:line') + check_eq(Editor_state.screen_top1.pos, 1, 'F - test_initial_state/screen_top:pos') +end + +function test_backspace_from_start_of_final_line() + io.write('\ntest_backspace_from_start_of_final_line') + -- display final line of text with cursor at start of it + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def'} + Editor_state.screen_top1 = {line=2, pos=1} + Editor_state.cursor1 = {line=2, pos=1} + Text.redraw_all(Editor_state) + -- backspace scrolls up + edit.run_after_keychord(Editor_state, 'backspace') + check_eq(#Editor_state.lines, 1, 'F - test_backspace_from_start_of_final_line/#lines') + check_eq(Editor_state.cursor1.line, 1, 'F - test_backspace_from_start_of_final_line/cursor') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_backspace_from_start_of_final_line/screen_top') +end + +function test_insert_first_character() + io.write('\ntest_insert_first_character') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{} + Text.redraw_all(Editor_state) + edit.draw(Editor_state) + edit.run_after_textinput(Editor_state, 'a') + local y = Editor_state.top + App.screen.check(y, 'a', 'F - test_insert_first_character/screen:1') +end + +function test_press_ctrl() + io.write('\ntest_press_ctrl') + -- press ctrl while the cursor is on text + App.screen.init{width=50, height=80} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{''} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.run_after_keychord(Editor_state, 'C-m') +end + +function test_move_left() + io.write('\ntest_move_left') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'a'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=2} + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'left') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_move_left') +end + +function test_move_right() + io.write('\ntest_move_right') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'a'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'right') + check_eq(Editor_state.cursor1.pos, 2, 'F - test_move_right') +end + +function test_move_left_to_previous_line() + io.write('\ntest_move_left_to_previous_line') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'left') + check_eq(Editor_state.cursor1.line, 1, 'F - test_move_left_to_previous_line/line') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_move_left_to_previous_line/pos') -- past end of line +end + +function test_move_right_to_next_line() + io.write('\ntest_move_right_to_next_line') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=4} -- past end of line + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'right') + check_eq(Editor_state.cursor1.line, 2, 'F - test_move_right_to_next_line/line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_move_right_to_next_line/pos') +end + +function test_move_to_start_of_word() + io.write('\ntest_move_to_start_of_word') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=3} + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-left') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_move_to_start_of_word') +end + +function test_move_to_start_of_previous_word() + io.write('\ntest_move_to_start_of_previous_word') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=4} -- at the space between words + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-left') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_move_to_start_of_previous_word') +end + +function test_skip_to_previous_word() + io.write('\ntest_skip_to_previous_word') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=5} -- at the start of second word + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-left') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_skip_to_previous_word') +end + +function test_skip_past_tab_to_previous_word() + io.write('\ntest_skip_past_tab_to_previous_word') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def\tghi'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=10} -- within third word + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-left') + check_eq(Editor_state.cursor1.pos, 9, 'F - test_skip_past_tab_to_previous_word') +end + +function test_skip_multiple_spaces_to_previous_word() + io.write('\ntest_skip_multiple_spaces_to_previous_word') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=6} -- at the start of second word + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-left') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_skip_multiple_spaces_to_previous_word') +end + +function test_move_to_start_of_word_on_previous_line() + io.write('\ntest_move_to_start_of_word_on_previous_line') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def', 'ghi'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-left') + check_eq(Editor_state.cursor1.line, 1, 'F - test_move_to_start_of_word_on_previous_line/line') + check_eq(Editor_state.cursor1.pos, 5, 'F - test_move_to_start_of_word_on_previous_line/pos') +end + +function test_move_past_end_of_word() + io.write('\ntest_move_past_end_of_word') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-right') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_move_past_end_of_word') +end + +function test_skip_to_next_word() + io.write('\ntest_skip_to_next_word') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=4} -- at the space between words + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-right') + check_eq(Editor_state.cursor1.pos, 8, 'F - test_skip_to_next_word') +end + +function test_skip_past_tab_to_next_word() + io.write('\ntest_skip_past_tab_to_next_word') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc\tdef'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} -- at the space between words + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-right') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_skip_past_tab_to_next_word') +end + +function test_skip_multiple_spaces_to_next_word() + io.write('\ntest_skip_multiple_spaces_to_next_word') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=4} -- at the start of second word + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-right') + check_eq(Editor_state.cursor1.pos, 9, 'F - test_skip_multiple_spaces_to_next_word') +end + +function test_move_past_end_of_word_on_next_line() + io.write('\ntest_move_past_end_of_word_on_next_line') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def', 'ghi'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=8} + edit.draw(Editor_state) + edit.run_after_keychord(Editor_state, 'M-right') + check_eq(Editor_state.cursor1.line, 2, 'F - test_move_past_end_of_word_on_next_line/line') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_move_past_end_of_word_on_next_line/pos') +end + +function test_click_with_mouse() + io.write('\ntest_click_with_mouse') + -- display two lines with cursor on one of them + App.screen.init{width=50, height=80} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- click on the other line + edit.draw(Editor_state) + edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + -- cursor moves + check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse/cursor:line') +end + +function test_click_with_mouse_to_left_of_line() + io.write('\ntest_click_with_mouse_to_left_of_line') + -- display a line with the cursor in the middle + App.screen.init{width=50, height=80} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=3} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- click to the left of the line + edit.draw(Editor_state) + edit.run_after_mouse_click(Editor_state, Editor_state.left-4,Editor_state.top+5, 1) + -- cursor moves to start of line + check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_to_left_of_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_click_with_mouse_to_left_of_line/cursor:pos') +end + +function test_click_with_mouse_takes_margins_into_account() + io.write('\ntest_click_with_mouse_takes_margins_into_account') + -- display two lines with cursor on one of them + App.screen.init{width=100, height=80} + Editor_state = edit.initialize_test_state() + Editor_state.left = 50 -- occupy only right side of screen + Editor_state.lines = load_array{'abc', 'def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- click on the other line + edit.draw(Editor_state) + edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + -- cursor moves + check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_takes_margins_into_account/cursor:line') + check_eq(Editor_state.cursor1.pos, 2, 'F - test_click_with_mouse_takes_margins_into_account/cursor:pos') +end + +function test_click_with_mouse_on_empty_line() + io.write('\ntest_click_with_mouse_on_empty_line') + -- display two lines with the first one empty + App.screen.init{width=50, height=80} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'', 'def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- click on the empty line + edit.draw(Editor_state) + edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + -- cursor moves + check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_on_empty_line/cursor') +end + +function test_draw_text() + io.write('\ntest_draw_text') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_draw_text/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_draw_text/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_draw_text/screen:3') +end + +function test_draw_wrapping_text() + io.write('\ntest_draw_wrapping_text') + App.screen.init{width=50, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'defgh', 'xyz'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_draw_wrapping_text/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'de', 'F - test_draw_wrapping_text/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'fgh', 'F - test_draw_wrapping_text/screen:3') +end + +function test_draw_word_wrapping_text() + io.write('\ntest_draw_word_wrapping_text') + App.screen.init{width=60, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc ', 'F - test_draw_word_wrapping_text/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def ', 'F - test_draw_word_wrapping_text/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_draw_word_wrapping_text/screen:3') +end + +function test_click_with_mouse_on_wrapping_line() + io.write('\ntest_click_with_mouse_on_wrapping_line') + -- display two lines with cursor on one of them + App.screen.init{width=50, height=80} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def ghi jkl mno pqr stu'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=20} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- click on the other line + edit.draw(Editor_state) + edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + -- cursor moves + check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_on_wrapping_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 2, 'F - test_click_with_mouse_on_wrapping_line/cursor:pos') +end + +function test_click_with_mouse_on_wrapping_line_takes_margins_into_account() + io.write('\ntest_click_with_mouse_on_wrapping_line_takes_margins_into_account') + -- display two lines with cursor on one of them + App.screen.init{width=100, height=80} + Editor_state = edit.initialize_test_state() + Editor_state.left = 50 -- occupy only right side of screen + Editor_state.lines = load_array{'abc def ghi jkl mno pqr stu'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=20} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- click on the other line + edit.draw(Editor_state) + edit.run_after_mouse_click(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + -- cursor moves + check_eq(Editor_state.cursor1.line, 1, 'F - test_click_with_mouse_on_wrapping_line_takes_margins_into_account/cursor:line') + check_eq(Editor_state.cursor1.pos, 2, 'F - test_click_with_mouse_on_wrapping_line_takes_margins_into_account/cursor:pos') +end + +function test_draw_text_wrapping_within_word() + -- arrange a screen line that needs to be split within a word + io.write('\ntest_draw_text_wrapping_within_word') + App.screen.init{width=60, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abcd e fghijk', 'xyz'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abcd ', 'F - test_draw_text_wrapping_within_word/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'e fgh', 'F - test_draw_text_wrapping_within_word/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ijk', 'F - test_draw_text_wrapping_within_word/screen:3') +end + +function test_draw_wrapping_text_containing_non_ascii() + -- draw a long line containing non-ASCII + io.write('\ntest_draw_wrapping_text_containing_non_ascii') + App.screen.init{width=60, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'madam I’m adam', 'xyz'} -- notice the non-ASCII apostrophe + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'mad', 'F - test_draw_wrapping_text_containing_non_ascii/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'am I', 'F - test_draw_wrapping_text_containing_non_ascii/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, '’m a', 'F - test_draw_wrapping_text_containing_non_ascii/screen:3') +end + +function test_click_on_wrapping_line() + io.write('\ntest_click_on_wrapping_line') + -- display a wrapping line + App.screen.init{width=75, height=80} + Editor_state = edit.initialize_test_state() + -- 12345678901234 + Editor_state.lines = load_array{"madam I'm adam"} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'madam ', 'F - test_click_on_wrapping_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, "I'm ad", 'F - test_click_on_wrapping_line/baseline/screen:2') + y = y + Editor_state.line_height + -- click past end of second screen line + edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1) + -- cursor moves to end of screen line + check_eq(Editor_state.cursor1.line, 1, 'F - test_click_on_wrapping_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 12, 'F - test_click_on_wrapping_line/cursor:pos') +end + +function test_click_on_wrapping_line_rendered_from_partway_at_top_of_screen() + io.write('\ntest_click_on_wrapping_line_rendered_from_partway_at_top_of_screen') + -- display a wrapping line from its second screen line + App.screen.init{width=75, height=80} + Editor_state = edit.initialize_test_state() + -- 12345678901234 + Editor_state.lines = load_array{"madam I'm adam"} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=8} + Editor_state.screen_top1 = {line=1, pos=7} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, "I'm ad", 'F - test_click_on_wrapping_line_rendered_from_partway_at_top_of_screen/baseline/screen:2') + y = y + Editor_state.line_height + -- click past end of second screen line + edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1) + -- cursor moves to end of screen line + check_eq(Editor_state.cursor1.line, 1, 'F - test_click_on_wrapping_line_rendered_from_partway_at_top_of_screen/cursor:line') + check_eq(Editor_state.cursor1.pos, 12, 'F - test_click_on_wrapping_line_rendered_from_partway_at_top_of_screen/cursor:pos') +end + +function test_click_past_end_of_wrapping_line() + io.write('\ntest_click_past_end_of_wrapping_line') + -- display a wrapping line + App.screen.init{width=75, height=80} + Editor_state = edit.initialize_test_state() + -- 12345678901234 + Editor_state.lines = load_array{"madam I'm adam"} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'madam ', 'F - test_click_past_end_of_wrapping_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, "I'm ad", 'F - test_click_past_end_of_wrapping_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'am', 'F - test_click_past_end_of_wrapping_line/baseline/screen:3') + y = y + Editor_state.line_height + -- click past the end of it + edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1) + -- cursor moves to end of line + check_eq(Editor_state.cursor1.pos, 15, 'F - test_click_past_end_of_wrapping_line/cursor') -- one more than the number of UTF-8 code-points +end + +function test_click_past_end_of_wrapping_line_containing_non_ascii() + io.write('\ntest_click_past_end_of_wrapping_line_containing_non_ascii') + -- display a wrapping line containing non-ASCII + App.screen.init{width=75, height=80} + Editor_state = edit.initialize_test_state() + -- 12345678901234 + Editor_state.lines = load_array{'madam I’m adam'} -- notice the non-ASCII apostrophe + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'madam ', 'F - test_click_past_end_of_wrapping_line_containing_non_ascii/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'I’m ad', 'F - test_click_past_end_of_wrapping_line_containing_non_ascii/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'am', 'F - test_click_past_end_of_wrapping_line_containing_non_ascii/baseline/screen:3') + y = y + Editor_state.line_height + -- click past the end of it + edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1) + -- cursor moves to end of line + check_eq(Editor_state.cursor1.pos, 15, 'F - test_click_past_end_of_wrapping_line_containing_non_ascii/cursor') -- one more than the number of UTF-8 code-points +end + +function test_click_past_end_of_word_wrapping_line() + io.write('\ntest_click_past_end_of_word_wrapping_line') + -- display a long line wrapping at a word boundary on a screen of more realistic length + App.screen.init{width=160, height=80} + Editor_state = edit.initialize_test_state() + -- 0 1 2 + -- 123456789012345678901 + Editor_state.lines = load_array{'the quick brown fox jumped over the lazy dog'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'the quick brown fox ', 'F - test_click_past_end_of_word_wrapping_line/baseline/screen:1') + y = y + Editor_state.line_height + -- click past the end of the screen line + edit.run_after_mouse_click(Editor_state, App.screen.width-2,y-2, 1) + -- cursor moves to end of screen line + check_eq(Editor_state.cursor1.pos, 20, 'F - test_click_past_end_of_word_wrapping_line/cursor') +end + +function test_edit_wrapping_text() + io.write('\ntest_edit_wrapping_text') + App.screen.init{width=50, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'xyz'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=4} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + edit.run_after_textinput(Editor_state, 'g') + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_edit_wrapping_text/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'de', 'F - test_edit_wrapping_text/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'fg', 'F - test_edit_wrapping_text/screen:3') +end + +function test_insert_newline() + io.write('\ntest_insert_newline') + -- display a few lines + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_insert_newline/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_insert_newline/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_insert_newline/baseline/screen:3') + -- hitting the enter key splits the line + edit.run_after_keychord(Editor_state, 'return') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_insert_newline/screen_top') + check_eq(Editor_state.cursor1.line, 2, 'F - test_insert_newline/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_insert_newline/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'a', 'F - test_insert_newline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'bc', 'F - test_insert_newline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_insert_newline/screen:3') +end + +function test_insert_newline_at_start_of_line() + io.write('\ntest_insert_newline_at_start_of_line') + -- display a line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- hitting the enter key splits the line + edit.run_after_keychord(Editor_state, 'return') + check_eq(Editor_state.cursor1.line, 2, 'F - test_insert_newline_at_start_of_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_insert_newline_at_start_of_line/cursor:pos') + check_eq(Editor_state.lines[1].data, '', 'F - test_insert_newline_at_start_of_line/data:1') + check_eq(Editor_state.lines[2].data, 'abc', 'F - test_insert_newline_at_start_of_line/data:2') +end + +function test_insert_from_clipboard() + io.write('\ntest_insert_from_clipboard') + -- display a few lines + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_insert_from_clipboard/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_insert_from_clipboard/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_insert_from_clipboard/baseline/screen:3') + -- paste some text including a newline, check that new line is created + App.clipboard = 'xy\nz' + edit.run_after_keychord(Editor_state, 'C-v') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_insert_from_clipboard/screen_top') + check_eq(Editor_state.cursor1.line, 2, 'F - test_insert_from_clipboard/cursor:line') + check_eq(Editor_state.cursor1.pos, 2, 'F - test_insert_from_clipboard/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'axy', 'F - test_insert_from_clipboard/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'zbc', 'F - test_insert_from_clipboard/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_insert_from_clipboard/screen:3') +end + +function test_move_cursor_using_mouse() + io.write('\ntest_move_cursor_using_mouse') + App.screen.init{width=50, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'xyz'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) -- populate line_cache.starty for each line Editor_state.line_cache + edit.run_after_mouse_press(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + check_eq(Editor_state.cursor1.line, 1, 'F - test_move_cursor_using_mouse/cursor:line') + check_eq(Editor_state.cursor1.pos, 2, 'F - test_move_cursor_using_mouse/cursor:pos') +end + +function test_pagedown() + io.write('\ntest_pagedown') + App.screen.init{width=120, height=45} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- initially the first two lines are displayed + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_pagedown/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_pagedown/baseline/screen:2') + -- after pagedown the bottom line becomes the top + edit.run_after_keychord(Editor_state, 'pagedown') + check_eq(Editor_state.screen_top1.line, 2, 'F - test_pagedown/screen_top') + check_eq(Editor_state.cursor1.line, 2, 'F - test_pagedown/cursor') + y = Editor_state.top + App.screen.check(y, 'def', 'F - test_pagedown/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_pagedown/screen:2') +end + +function test_pagedown_can_start_from_middle_of_long_wrapping_line() + io.write('\ntest_pagedown_can_start_from_middle_of_long_wrapping_line') + -- draw a few lines starting from a very long wrapping line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def ghi jkl mno pqr stu vwx yza bcd efg hij', 'XYZ'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/baseline/screen:3') + -- after pagedown we scroll down the very long wrapping line + edit.run_after_keychord(Editor_state, 'pagedown') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen_top:line') + check_eq(Editor_state.screen_top1.pos, 9, 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen_top:pos') + y = Editor_state.top + App.screen.check(y, 'ghi ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'mno ', 'F - test_pagedown_can_start_from_middle_of_long_wrapping_line/screen:3') +end + +function test_pagedown_never_moves_up() + io.write('\ntest_pagedown_never_moves_up') + -- draw the final screen line of a wrapping line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def ghi'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=9} + Editor_state.screen_top1 = {line=1, pos=9} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- pagedown makes no change + edit.run_after_keychord(Editor_state, 'pagedown') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_pagedown_never_moves_up/screen_top:line') + check_eq(Editor_state.screen_top1.pos, 9, 'F - test_pagedown_never_moves_up/screen_top:pos') +end + +function test_down_arrow_moves_cursor() + io.write('\ntest_down_arrow_moves_cursor') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- initially the first three lines are displayed + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_down_arrow_moves_cursor/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_down_arrow_moves_cursor/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_down_arrow_moves_cursor/baseline/screen:3') + -- after hitting the down arrow, the cursor moves down by 1 line + edit.run_after_keychord(Editor_state, 'down') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_down_arrow_moves_cursor/screen_top') + check_eq(Editor_state.cursor1.line, 2, 'F - test_down_arrow_moves_cursor/cursor') + -- the screen is unchanged + y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_down_arrow_moves_cursor/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_down_arrow_moves_cursor/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_down_arrow_moves_cursor/screen:3') +end + +function test_down_arrow_scrolls_down_by_one_line() + io.write('\ntest_down_arrow_scrolls_down_by_one_line') + -- display the first three lines with the cursor on the bottom line + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=3, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_down_arrow_scrolls_down_by_one_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_down_arrow_scrolls_down_by_one_line/baseline/screen:3') + -- after hitting the down arrow the screen scrolls down by one line + edit.run_after_keychord(Editor_state, 'down') + check_eq(Editor_state.screen_top1.line, 2, 'F - test_down_arrow_scrolls_down_by_one_line/screen_top') + check_eq(Editor_state.cursor1.line, 4, 'F - test_down_arrow_scrolls_down_by_one_line/cursor') + y = Editor_state.top + App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_down_arrow_scrolls_down_by_one_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_down_arrow_scrolls_down_by_one_line/screen:3') +end + +function test_down_arrow_scrolls_down_by_one_screen_line() + io.write('\ntest_down_arrow_scrolls_down_by_one_screen_line') + -- display the first three lines with the cursor on the bottom line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=3, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_down_arrow_scrolls_down_by_one_screen_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_screen_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi ', 'F - test_down_arrow_scrolls_down_by_one_screen_line/baseline/screen:3') -- line wrapping includes trailing whitespace + -- after hitting the down arrow the screen scrolls down by one line + edit.run_after_keychord(Editor_state, 'down') + check_eq(Editor_state.screen_top1.line, 2, 'F - test_down_arrow_scrolls_down_by_one_screen_line/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_down_arrow_scrolls_down_by_one_screen_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 5, 'F - test_down_arrow_scrolls_down_by_one_screen_line/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_screen_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi ', 'F - test_down_arrow_scrolls_down_by_one_screen_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_down_arrow_scrolls_down_by_one_screen_line/screen:3') +end + +function test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word() + io.write('\ntest_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word') + -- display the first three lines with the cursor on the bottom line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghijkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=3, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghij', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/baseline/screen:3') + -- after hitting the down arrow the screen scrolls down by one line + edit.run_after_keychord(Editor_state, 'down') + check_eq(Editor_state.screen_top1.line, 2, 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/cursor:line') + check_eq(Editor_state.cursor1.pos, 5, 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'def', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghij', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'kl', 'F - test_down_arrow_scrolls_down_by_one_screen_line_after_splitting_within_word/screen:3') +end + +function test_page_down_followed_by_down_arrow_does_not_scroll_screen_up() + io.write('\ntest_page_down_followed_by_down_arrow_does_not_scroll_screen_up') + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghijkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=3, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghij', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline/screen:3') + -- after hitting pagedown the screen scrolls down to start of a long line + edit.run_after_keychord(Editor_state, 'pagedown') + check_eq(Editor_state.screen_top1.line, 3, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline2/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline2/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/baseline2/cursor:pos') + -- after hitting down arrow the screen doesn't scroll down further, and certainly doesn't scroll up + edit.run_after_keychord(Editor_state, 'down') + check_eq(Editor_state.screen_top1.line, 3, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/cursor:line') + check_eq(Editor_state.cursor1.pos, 5, 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'ghij', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'kl', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_page_down_followed_by_down_arrow_does_not_scroll_screen_up/screen:3') +end + +function test_up_arrow_moves_cursor() + io.write('\ntest_up_arrow_moves_cursor') + -- display the first 3 lines with the cursor on the bottom line + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=3, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_up_arrow_moves_cursor/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_up_arrow_moves_cursor/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_up_arrow_moves_cursor/baseline/screen:3') + -- after hitting the up arrow the cursor moves up by 1 line + edit.run_after_keychord(Editor_state, 'up') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_up_arrow_moves_cursor/screen_top') + check_eq(Editor_state.cursor1.line, 2, 'F - test_up_arrow_moves_cursor/cursor') + -- the screen is unchanged + y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_up_arrow_moves_cursor/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_up_arrow_moves_cursor/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_up_arrow_moves_cursor/screen:3') +end + +function test_up_arrow_scrolls_up_by_one_line() + io.write('\ntest_up_arrow_scrolls_up_by_one_line') + -- display the lines 2/3/4 with the cursor on line 2 + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=2, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_by_one_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_by_one_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_by_one_line/baseline/screen:3') + -- after hitting the up arrow the screen scrolls up by one line + edit.run_after_keychord(Editor_state, 'up') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_up_arrow_scrolls_up_by_one_line/screen_top') + check_eq(Editor_state.cursor1.line, 1, 'F - test_up_arrow_scrolls_up_by_one_line/cursor') + y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_up_arrow_scrolls_up_by_one_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_by_one_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_by_one_line/screen:3') +end + +function test_up_arrow_scrolls_up_by_one_screen_line() + io.write('\ntest_up_arrow_scrolls_up_by_one_screen_line') + -- display lines starting from second screen line of a line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=3, pos=6} + Editor_state.screen_top1 = {line=3, pos=5} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_by_one_screen_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_up_arrow_scrolls_up_by_one_screen_line/baseline/screen:2') + -- after hitting the up arrow the screen scrolls up to first screen line + edit.run_after_keychord(Editor_state, 'up') + y = Editor_state.top + App.screen.check(y, 'ghi ', 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen:3') + check_eq(Editor_state.screen_top1.line, 3, 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen_top') + check_eq(Editor_state.screen_top1.pos, 1, 'F - test_up_arrow_scrolls_up_by_one_screen_line/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_up_arrow_scrolls_up_by_one_screen_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_up_arrow_scrolls_up_by_one_screen_line/cursor:pos') +end + +function test_up_arrow_scrolls_up_to_final_screen_line() + io.write('\ntest_up_arrow_scrolls_up_to_final_screen_line') + -- display lines starting just after a long line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def', 'ghi', 'jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=2, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_to_final_screen_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_to_final_screen_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_up_arrow_scrolls_up_to_final_screen_line/baseline/screen:3') + -- after hitting the up arrow the screen scrolls up to final screen line of previous line + edit.run_after_keychord(Editor_state, 'up') + y = Editor_state.top + App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen:3') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen_top') + check_eq(Editor_state.screen_top1.pos, 5, 'F - test_up_arrow_scrolls_up_to_final_screen_line/screen_top') + check_eq(Editor_state.cursor1.line, 1, 'F - test_up_arrow_scrolls_up_to_final_screen_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 5, 'F - test_up_arrow_scrolls_up_to_final_screen_line/cursor:pos') +end + +function test_up_arrow_scrolls_up_to_empty_line() + io.write('\ntest_up_arrow_scrolls_up_to_empty_line') + -- display a screenful of text with an empty line just above it outside the screen + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'', 'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=2, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_up_arrow_scrolls_up_to_empty_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_to_empty_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_up_arrow_scrolls_up_to_empty_line/baseline/screen:3') + -- after hitting the up arrow the screen scrolls up by one line + edit.run_after_keychord(Editor_state, 'up') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_up_arrow_scrolls_up_to_empty_line/screen_top') + check_eq(Editor_state.cursor1.line, 1, 'F - test_up_arrow_scrolls_up_to_empty_line/cursor') + y = Editor_state.top + -- empty first line + y = y + Editor_state.line_height + App.screen.check(y, 'abc', 'F - test_up_arrow_scrolls_up_to_empty_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_up_arrow_scrolls_up_to_empty_line/screen:3') +end + +function test_pageup() + io.write('\ntest_pageup') + App.screen.init{width=120, height=45} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=2, pos=1} + Editor_state.screen_bottom1 = {} + -- initially the last two lines are displayed + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'def', 'F - test_pageup/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_pageup/baseline/screen:2') + -- after pageup the cursor goes to first line + edit.run_after_keychord(Editor_state, 'pageup') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_pageup/screen_top') + check_eq(Editor_state.cursor1.line, 1, 'F - test_pageup/cursor') + y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_pageup/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_pageup/screen:2') +end + +function test_pageup_scrolls_up_by_screen_line() + io.write('\ntest_pageup_scrolls_up_by_screen_line') + -- display the first three lines with the cursor on the bottom line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def', 'ghi', 'jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=2, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'ghi', 'F - test_pageup_scrolls_up_by_screen_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_pageup_scrolls_up_by_screen_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_pageup_scrolls_up_by_screen_line/baseline/screen:3') -- line wrapping includes trailing whitespace + -- after hitting the page-up key the screen scrolls up to top + edit.run_after_keychord(Editor_state, 'pageup') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_pageup_scrolls_up_by_screen_line/screen_top') + check_eq(Editor_state.cursor1.line, 1, 'F - test_pageup_scrolls_up_by_screen_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_pageup_scrolls_up_by_screen_line/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'abc ', 'F - test_pageup_scrolls_up_by_screen_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_pageup_scrolls_up_by_screen_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_pageup_scrolls_up_by_screen_line/screen:3') +end + +function test_pageup_scrolls_up_from_middle_screen_line() + io.write('\ntest_pageup_scrolls_up_from_middle_screen_line') + -- display a few lines starting from the middle of a line (Editor_state.cursor1.pos > 1) + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def', 'ghi jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=5} + Editor_state.screen_top1 = {line=2, pos=5} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'jkl', 'F - test_pageup_scrolls_up_from_middle_screen_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_pageup_scrolls_up_from_middle_screen_line/baseline/screen:3') -- line wrapping includes trailing whitespace + -- after hitting the page-up key the screen scrolls up to top + edit.run_after_keychord(Editor_state, 'pageup') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_pageup_scrolls_up_from_middle_screen_line/screen_top') + check_eq(Editor_state.cursor1.line, 1, 'F - test_pageup_scrolls_up_from_middle_screen_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_pageup_scrolls_up_from_middle_screen_line/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'abc ', 'F - test_pageup_scrolls_up_from_middle_screen_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_pageup_scrolls_up_from_middle_screen_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi ', 'F - test_pageup_scrolls_up_from_middle_screen_line/screen:3') +end + +function test_enter_on_bottom_line_scrolls_down() + io.write('\ntest_enter_on_bottom_line_scrolls_down') + -- display a few lines with cursor on bottom line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=3, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_enter_on_bottom_line_scrolls_down/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_enter_on_bottom_line_scrolls_down/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_enter_on_bottom_line_scrolls_down/baseline/screen:3') + -- after hitting the enter key the screen scrolls down + edit.run_after_keychord(Editor_state, 'return') + check_eq(Editor_state.screen_top1.line, 2, 'F - test_enter_on_bottom_line_scrolls_down/screen_top') + check_eq(Editor_state.cursor1.line, 4, 'F - test_enter_on_bottom_line_scrolls_down/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_enter_on_bottom_line_scrolls_down/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'def', 'F - test_enter_on_bottom_line_scrolls_down/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'g', 'F - test_enter_on_bottom_line_scrolls_down/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'hi', 'F - test_enter_on_bottom_line_scrolls_down/screen:3') +end + +function test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom() + io.write('\ntest_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom') + -- display just the bottom line on screen + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=4, pos=2} + Editor_state.screen_top1 = {line=4, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'jkl', 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/baseline/screen:1') + -- after hitting the enter key the screen does not scroll down + edit.run_after_keychord(Editor_state, 'return') + check_eq(Editor_state.screen_top1.line, 4, 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen_top') + check_eq(Editor_state.cursor1.line, 5, 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'j', 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'kl', 'F - test_enter_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen:2') +end + +function test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom() + io.write('\ntest_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom') + -- display just an empty bottom line on screen + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', ''} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=2, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- after hitting the inserting_text key the screen does not scroll down + edit.run_after_textinput(Editor_state, 'a') + check_eq(Editor_state.screen_top1.line, 2, 'F - test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen_top') + check_eq(Editor_state.cursor1.line, 2, 'F - test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom/cursor:line') + check_eq(Editor_state.cursor1.pos, 2, 'F - test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom/cursor:pos') + local y = Editor_state.top + App.screen.check(y, 'a', 'F - test_inserting_text_on_final_line_avoids_scrolling_down_when_not_at_bottom/screen:1') +end + +function test_typing_on_bottom_line_scrolls_down() + io.write('\ntest_typing_on_bottom_line_scrolls_down') + -- display a few lines with cursor on bottom line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=3, pos=4} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_typing_on_bottom_line_scrolls_down/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_typing_on_bottom_line_scrolls_down/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_typing_on_bottom_line_scrolls_down/baseline/screen:3') + -- after typing something the line wraps and the screen scrolls down + edit.run_after_textinput(Editor_state, 'j') + edit.run_after_textinput(Editor_state, 'k') + edit.run_after_textinput(Editor_state, 'l') + check_eq(Editor_state.screen_top1.line, 2, 'F - test_typing_on_bottom_line_scrolls_down/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_typing_on_bottom_line_scrolls_down/cursor:line') + check_eq(Editor_state.cursor1.pos, 7, 'F - test_typing_on_bottom_line_scrolls_down/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'def', 'F - test_typing_on_bottom_line_scrolls_down/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghij', 'F - test_typing_on_bottom_line_scrolls_down/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'kl', 'F - test_typing_on_bottom_line_scrolls_down/screen:3') +end + +function test_left_arrow_scrolls_up_in_wrapped_line() + io.write('\ntest_left_arrow_scrolls_up_in_wrapped_line') + -- display lines starting from second screen line of a line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.screen_top1 = {line=3, pos=5} + Editor_state.screen_bottom1 = {} + -- cursor is at top of screen + Editor_state.cursor1 = {line=3, pos=5} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'jkl', 'F - test_left_arrow_scrolls_up_in_wrapped_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_left_arrow_scrolls_up_in_wrapped_line/baseline/screen:2') + -- after hitting the left arrow the screen scrolls up to first screen line + edit.run_after_keychord(Editor_state, 'left') + y = Editor_state.top + App.screen.check(y, 'ghi ', 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen:3') + check_eq(Editor_state.screen_top1.line, 3, 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen_top') + check_eq(Editor_state.screen_top1.pos, 1, 'F - test_left_arrow_scrolls_up_in_wrapped_line/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_left_arrow_scrolls_up_in_wrapped_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_left_arrow_scrolls_up_in_wrapped_line/cursor:pos') +end + +function test_right_arrow_scrolls_down_in_wrapped_line() + io.write('\ntest_right_arrow_scrolls_down_in_wrapped_line') + -- display the first three lines with the cursor on the bottom line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- cursor is at bottom right of screen + Editor_state.cursor1 = {line=3, pos=5} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_right_arrow_scrolls_down_in_wrapped_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_right_arrow_scrolls_down_in_wrapped_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi ', 'F - test_right_arrow_scrolls_down_in_wrapped_line/baseline/screen:3') -- line wrapping includes trailing whitespace + -- after hitting the right arrow the screen scrolls down by one line + edit.run_after_keychord(Editor_state, 'right') + check_eq(Editor_state.screen_top1.line, 2, 'F - test_right_arrow_scrolls_down_in_wrapped_line/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_right_arrow_scrolls_down_in_wrapped_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 6, 'F - test_right_arrow_scrolls_down_in_wrapped_line/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'def', 'F - test_right_arrow_scrolls_down_in_wrapped_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi ', 'F - test_right_arrow_scrolls_down_in_wrapped_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_right_arrow_scrolls_down_in_wrapped_line/screen:3') +end + +function test_home_scrolls_up_in_wrapped_line() + io.write('\ntest_home_scrolls_up_in_wrapped_line') + -- display lines starting from second screen line of a line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.screen_top1 = {line=3, pos=5} + Editor_state.screen_bottom1 = {} + -- cursor is at top of screen + Editor_state.cursor1 = {line=3, pos=5} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'jkl', 'F - test_home_scrolls_up_in_wrapped_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_home_scrolls_up_in_wrapped_line/baseline/screen:2') + -- after hitting home the screen scrolls up to first screen line + edit.run_after_keychord(Editor_state, 'home') + y = Editor_state.top + App.screen.check(y, 'ghi ', 'F - test_home_scrolls_up_in_wrapped_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_home_scrolls_up_in_wrapped_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_home_scrolls_up_in_wrapped_line/screen:3') + check_eq(Editor_state.screen_top1.line, 3, 'F - test_home_scrolls_up_in_wrapped_line/screen_top') + check_eq(Editor_state.screen_top1.pos, 1, 'F - test_home_scrolls_up_in_wrapped_line/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_home_scrolls_up_in_wrapped_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_home_scrolls_up_in_wrapped_line/cursor:pos') +end + +function test_end_scrolls_down_in_wrapped_line() + io.write('\ntest_end_scrolls_down_in_wrapped_line') + -- display the first three lines with the cursor on the bottom line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- cursor is at bottom right of screen + Editor_state.cursor1 = {line=3, pos=5} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_end_scrolls_down_in_wrapped_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_end_scrolls_down_in_wrapped_line/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi ', 'F - test_end_scrolls_down_in_wrapped_line/baseline/screen:3') -- line wrapping includes trailing whitespace + -- after hitting end the screen scrolls down by one line + edit.run_after_keychord(Editor_state, 'end') + check_eq(Editor_state.screen_top1.line, 2, 'F - test_end_scrolls_down_in_wrapped_line/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_end_scrolls_down_in_wrapped_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 8, 'F - test_end_scrolls_down_in_wrapped_line/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'def', 'F - test_end_scrolls_down_in_wrapped_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi ', 'F - test_end_scrolls_down_in_wrapped_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_end_scrolls_down_in_wrapped_line/screen:3') +end + +function test_position_cursor_on_recently_edited_wrapping_line() + -- draw a line wrapping over 2 screen lines + io.write('\ntest_position_cursor_on_recently_edited_wrapping_line') + App.screen.init{width=100, height=200} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc def ghi jkl mno pqr ', 'xyz'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=25} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'abc def ghi ', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline1/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl mno pqr ', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline1/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'xyz', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline1/screen:3') + -- add to the line until it's wrapping over 3 screen lines + edit.run_after_textinput(Editor_state, 's') + edit.run_after_textinput(Editor_state, 't') + edit.run_after_textinput(Editor_state, 'u') + check_eq(Editor_state.cursor1.pos, 28, 'F - test_position_cursor_on_recently_edited_wrapping_line/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'abc def ghi ', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline2/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl mno pqr ', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline2/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'stu', 'F - test_position_cursor_on_recently_edited_wrapping_line/baseline2/screen:3') + -- try to move the cursor earlier in the third screen line by clicking the mouse + edit.run_after_mouse_press(Editor_state, Editor_state.left+8,Editor_state.top+Editor_state.line_height*2+5, 1) + -- cursor should move + check_eq(Editor_state.cursor1.line, 1, 'F - test_position_cursor_on_recently_edited_wrapping_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 26, 'F - test_position_cursor_on_recently_edited_wrapping_line/cursor:pos') +end + +function test_backspace_can_scroll_up() + io.write('\ntest_backspace_can_scroll_up') + -- display the lines 2/3/4 with the cursor on line 2 + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'jkl'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + Editor_state.screen_top1 = {line=2, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'def', 'F - test_backspace_can_scroll_up/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_backspace_can_scroll_up/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_backspace_can_scroll_up/baseline/screen:3') + -- after hitting backspace the screen scrolls up by one line + edit.run_after_keychord(Editor_state, 'backspace') + check_eq(Editor_state.screen_top1.line, 1, 'F - test_backspace_can_scroll_up/screen_top') + check_eq(Editor_state.cursor1.line, 1, 'F - test_backspace_can_scroll_up/cursor') + y = Editor_state.top + App.screen.check(y, 'abcdef', 'F - test_backspace_can_scroll_up/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_backspace_can_scroll_up/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_backspace_can_scroll_up/screen:3') +end + +function test_backspace_can_scroll_up_screen_line() + io.write('\ntest_backspace_can_scroll_up_screen_line') + -- display lines starting from second screen line of a line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi jkl', 'mno'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=3, pos=5} + Editor_state.screen_top1 = {line=3, pos=5} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + local y = Editor_state.top + App.screen.check(y, 'jkl', 'F - test_backspace_can_scroll_up_screen_line/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_backspace_can_scroll_up_screen_line/baseline/screen:2') + -- after hitting backspace the screen scrolls up by one screen line + edit.run_after_keychord(Editor_state, 'backspace') + y = Editor_state.top + App.screen.check(y, 'ghij', 'F - test_backspace_can_scroll_up_screen_line/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'kl', 'F - test_backspace_can_scroll_up_screen_line/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'mno', 'F - test_backspace_can_scroll_up_screen_line/screen:3') + check_eq(Editor_state.screen_top1.line, 3, 'F - test_backspace_can_scroll_up_screen_line/screen_top') + check_eq(Editor_state.screen_top1.pos, 1, 'F - test_backspace_can_scroll_up_screen_line/screen_top') + check_eq(Editor_state.cursor1.line, 3, 'F - test_backspace_can_scroll_up_screen_line/cursor:line') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_backspace_can_scroll_up_screen_line/cursor:pos') +end + +function test_backspace_past_line_boundary() + io.write('\ntest_backspace_past_line_boundary') + -- position cursor at start of a (non-first) line + App.screen.init{width=Editor_state.left+30, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=1} + -- backspace joins with previous line + edit.run_after_keychord(Editor_state, 'backspace') + check_eq(Editor_state.lines[1].data, 'abcdef', "F - test_backspace_past_line_boundary") +end + +function test_undo_insert_text() + io.write('\ntest_undo_insert_text') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'xyz'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=4} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- insert a character + edit.draw(Editor_state) + edit.run_after_textinput(Editor_state, 'g') + check_eq(Editor_state.cursor1.line, 2, 'F - test_undo_insert_text/baseline/cursor:line') + check_eq(Editor_state.cursor1.pos, 5, 'F - test_undo_insert_text/baseline/cursor:pos') + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_undo_insert_text/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'defg', 'F - test_undo_insert_text/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'xyz', 'F - test_undo_insert_text/baseline/screen:3') + -- undo + edit.run_after_keychord(Editor_state, 'C-z') + check_eq(Editor_state.cursor1.line, 2, 'F - test_undo_insert_text/cursor:line') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_undo_insert_text/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_undo_insert_text/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_undo_insert_text/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'xyz', 'F - test_undo_insert_text/screen:3') +end + +function test_undo_delete_text() + io.write('\ntest_undo_delete_text') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'defg', 'xyz'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=2, pos=5} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + -- delete a character + edit.run_after_keychord(Editor_state, 'backspace') + check_eq(Editor_state.cursor1.line, 2, 'F - test_undo_delete_text/baseline/cursor:line') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_undo_delete_text/baseline/cursor:pos') + local y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_undo_delete_text/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'def', 'F - test_undo_delete_text/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'xyz', 'F - test_undo_delete_text/baseline/screen:3') + -- undo +--? -- after undo, the backspaced key is selected + edit.run_after_keychord(Editor_state, 'C-z') + check_eq(Editor_state.cursor1.line, 2, 'F - test_undo_delete_text/cursor:line') + check_eq(Editor_state.cursor1.pos, 5, 'F - test_undo_delete_text/cursor:pos') + y = Editor_state.top + App.screen.check(y, 'abc', 'F - test_undo_delete_text/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'defg', 'F - test_undo_delete_text/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'xyz', 'F - test_undo_delete_text/screen:3') +end + +function test_search() + io.write('\ntest_search') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc', 'def', 'ghi', 'deg'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- search for a string + edit.run_after_keychord(Editor_state, 'C-f') + edit.run_after_textinput(Editor_state, 'd') + edit.run_after_keychord(Editor_state, 'return') + check_eq(Editor_state.cursor1.line, 2, 'F - test_search/1/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_search/1/cursor:pos') + -- reset cursor + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + -- search for second occurrence + edit.run_after_keychord(Editor_state, 'C-f') + edit.run_after_textinput(Editor_state, 'de') + edit.run_after_keychord(Editor_state, 'down') + edit.run_after_keychord(Editor_state, 'return') + check_eq(Editor_state.cursor1.line, 4, 'F - test_search/2/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_search/2/cursor:pos') +end + +function test_search_upwards() + io.write('\ntest_search_upwards') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc abd'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- search for a string + edit.run_after_keychord(Editor_state, 'C-f') + edit.run_after_textinput(Editor_state, 'a') + -- search for previous occurrence + edit.run_after_keychord(Editor_state, 'up') + check_eq(Editor_state.cursor1.line, 1, 'F - test_search_upwards/2/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_search_upwards/2/cursor:pos') +end + +function test_search_wrap() + io.write('\ntest_search_wrap') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=3} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- search for a string + edit.run_after_keychord(Editor_state, 'C-f') + edit.run_after_textinput(Editor_state, 'a') + edit.run_after_keychord(Editor_state, 'return') + -- cursor wraps + check_eq(Editor_state.cursor1.line, 1, 'F - test_search_wrap/1/cursor:line') + check_eq(Editor_state.cursor1.pos, 1, 'F - test_search_wrap/1/cursor:pos') +end + +function test_search_wrap_upwards() + io.write('\ntest_search_wrap_upwards') + App.screen.init{width=120, height=60} + Editor_state = edit.initialize_test_state() + Editor_state.lines = load_array{'abc abd'} + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- search upwards for a string + edit.run_after_keychord(Editor_state, 'C-f') + edit.run_after_textinput(Editor_state, 'a') + edit.run_after_keychord(Editor_state, 'up') + -- cursor wraps + check_eq(Editor_state.cursor1.line, 1, 'F - test_search_wrap_upwards/1/cursor:line') + check_eq(Editor_state.cursor1.pos, 5, 'F - test_search_wrap_upwards/1/cursor:pos') +end diff --git a/source_undo.lua b/source_undo.lua new file mode 100644 index 0000000..0aa6755 --- /dev/null +++ b/source_undo.lua @@ -0,0 +1,110 @@ +-- undo/redo by managing the sequence of events in the current session +-- based on https://github.com/akkartik/mu1/blob/master/edit/012-editor-undo.mu + +-- Incredibly inefficient; we make a copy of lines on every single keystroke. +-- The hope here is that we're either editing small files or just reading large files. +-- TODO: highlight stuff inserted by any undo/redo operation +-- TODO: coalesce multiple similar operations + +function record_undo_event(State, data) + State.history[State.next_history] = data + State.next_history = State.next_history+1 + for i=State.next_history,#State.history do + State.history[i] = nil + end +end + +function undo_event(State) + if State.next_history > 1 then +--? print('moving to history', State.next_history-1) + State.next_history = State.next_history-1 + local result = State.history[State.next_history] + return result + end +end + +function redo_event(State) + if State.next_history <= #State.history then +--? print('restoring history', State.next_history+1) + local result = State.history[State.next_history] + State.next_history = State.next_history+1 + return result + end +end + +-- Copy all relevant global state. +-- Make copies of objects; the rest of the app may mutate them in place, but undo requires immutable histories. +function snapshot(State, s,e) + -- Snapshot everything by default, but subset if requested. + assert(s) + if e == nil then + e = s + end + assert(#State.lines > 0) + if s < 1 then s = 1 end + if s > #State.lines then s = #State.lines end + if e < 1 then e = 1 end + if e > #State.lines then e = #State.lines end + -- compare with App.initialize_globals + local event = { + screen_top=deepcopy(State.screen_top1), + selection=deepcopy(State.selection1), + cursor=deepcopy(State.cursor1), + lines={}, + start_line=s, + end_line=e, + -- no filename; undo history is cleared when filename changes + } + -- deep copy lines without cached stuff like text fragments + for i=s,e do + local line = State.lines[i] + table.insert(event.lines, {data=line.data, dataB=line.dataB}) + end + return event +end + +function patch(lines, from, to) +--? if #from.lines == 1 and #to.lines == 1 then +--? assert(from.start_line == from.end_line) +--? assert(to.start_line == to.end_line) +--? assert(from.start_line == to.start_line) +--? lines[from.start_line] = to.lines[1] +--? return +--? end + assert(from.start_line == to.start_line) + for i=from.end_line,from.start_line,-1 do + table.remove(lines, i) + end + assert(#to.lines == to.end_line-to.start_line+1) + for i=1,#to.lines do + table.insert(lines, to.start_line+i-1, to.lines[i]) + end +end + +function patch_placeholders(line_cache, from, to) + assert(from.start_line == to.start_line) + for i=from.end_line,from.start_line,-1 do + table.remove(line_cache, i) + end + assert(#to.lines == to.end_line-to.start_line+1) + for i=1,#to.lines do + table.insert(line_cache, to.start_line+i-1, {}) + end +end + +-- https://stackoverflow.com/questions/640642/how-do-you-copy-a-lua-table-by-value/26367080#26367080 +function deepcopy(obj, seen) + if type(obj) ~= 'table' then return obj end + if seen and seen[obj] then return seen[obj] end + local s = seen or {} + local result = setmetatable({}, getmetatable(obj)) + s[obj] = result + for k,v in pairs(obj) do + result[deepcopy(k, s)] = deepcopy(v, s) + end + return result +end + +function minmax(a, b) + return math.min(a,b), math.max(a,b) +end diff --git a/text.lua b/text.lua index 2e3aa32..c2c633b 100644 --- a/text.lua +++ b/text.lua @@ -1,11 +1,6 @@ -- text editor, particularly text drawing, horizontal wrap, vertical scrolling Text = {} -require 'search' -require 'select' -require 'undo' -require 'text_tests' - -- draw a line starting from startpos to screen at y between State.left and State.right -- return the final y, and position of start of final screen line drawn function Text.draw(State, line_index, y, startpos)