From 73fefa7d0961d3831da6c8b2eb7b1b05e3614a69 Mon Sep 17 00:00:00 2001 From: "Kartik K. Agaram" Date: Tue, 6 Sep 2022 10:05:20 -0700 Subject: [PATCH] support selections in the source editor I've only tested side A so far, and included a statement of how I want side B to behave. --- main.lua | 2 +- source_edit.lua | 57 +++++- source_select.lua | 183 +++++++++++++++++++ source_text.lua | 61 ++++++- source_text_tests.lua | 410 +++++++++++++++++++++++++++++++++++++++++- text.lua | 1 - 6 files changed, 708 insertions(+), 6 deletions(-) create mode 100644 source_select.lua diff --git a/main.lua b/main.lua index efb7419..fd0fe45 100644 --- a/main.lua +++ b/main.lua @@ -60,7 +60,7 @@ function App.load() 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_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') diff --git a/source_edit.lua b/source_edit.lua index 6676b42..88d5e76 100644 --- a/source_edit.lua +++ b/source_edit.lua @@ -76,6 +76,14 @@ function edit.initialize_state(top, left, right, font_height, line_height) -- c 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 + selection1 = {}, + -- some extra state to compute selection between mouse press and release + old_cursor1 = nil, + old_selection1 = nil, + mousepress_shift = nil, + -- when selecting text, avoid recomputing some state on every single frame + recent_mouse = {}, + -- cursor coordinates in pixels cursor_x = 0, cursor_y = 0, @@ -208,9 +216,22 @@ function edit.mouse_pressed(State, x,y, mouse_button) for line_index,line in ipairs(State.lines) do if line.mode == 'text' then if Text.in_line(State, line_index, x,y) then + -- delicate dance between cursor, selection and old cursor/selection + -- scenarios: + -- regular press+release: sets cursor, clears selection + -- shift press+release: + -- sets selection to old cursor if not set otherwise leaves it untouched + -- sets cursor + -- press and hold to start a selection: sets selection on press, cursor on release + -- press and hold, then press shift: ignore shift + -- i.e. mouse_released should never look at shift state + State.old_cursor1 = State.cursor1 + State.old_selection1 = State.selection1 + State.mousepress_shift = App.shift_down() 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} + State.selection1 = {line=line_index, pos=pos, posB=posB} +--? print('selection', State.selection1.line, State.selection1.pos, State.selection1.posB) break end elseif line.mode == 'drawing' then @@ -236,6 +257,30 @@ function edit.mouse_released(State, x,y, mouse_button) record_undo_event(State, {before=Drawing.before, after=snapshot(State, State.lines.current_drawing_index)}) Drawing.before = nil end + else + for line_index,line in ipairs(State.lines) do + if line.mode == 'text' then + if Text.in_line(State, line_index, x,y) then +--? print('reset selection') + local pos,posB = Text.to_pos_on_line(State, line_index, x, y) + State.cursor1 = {line=line_index, pos=pos, posB=posB} +--? print('cursor', State.cursor1.line, State.cursor1.pos, State.cursor1.posB) + if State.mousepress_shift then + if State.old_selection1.line == nil then + State.selection1 = State.old_cursor1 + else + State.selection1 = State.old_selection1 + end + end + State.old_cursor1, State.old_selection1, State.mousepress_shift = nil + if eq(State.cursor1, State.selection1) then + State.selection1 = {} + end + break + end + end + end +--? print('selection:', State.selection1.line, State.selection1.pos) end end @@ -258,6 +303,14 @@ function edit.textinput(State, t) end function edit.keychord_pressed(State, chord, key) + if State.selection1.line and + not State.lines.current_drawing and + -- printable character created using shift key => delete selection + -- (we're not creating any ctrl-shift- or alt-shift- combinations using regular/printable keys) + (not App.shift_down() or utf8.len(key) == 1) and + chord ~= 'C-c' and chord ~= 'C-x' and chord ~= 'backspace' and backspace ~= 'delete' and not App.is_cursor_movement(chord) then + Text.delete_selection(State, State.left, State.right) + end if State.search_term then if chord == 'escape' then State.search_term = nil @@ -336,6 +389,7 @@ function edit.keychord_pressed(State, chord, key) local src = event.before State.screen_top1 = deepcopy(src.screen_top) State.cursor1 = deepcopy(src.cursor) + State.selection1 = deepcopy(src.selection) patch(State.lines, event.after, event.before) patch_placeholders(State.line_cache, event.after, event.before) -- invalidate various cached bits of lines @@ -351,6 +405,7 @@ function edit.keychord_pressed(State, chord, key) local src = event.after State.screen_top1 = deepcopy(src.screen_top) State.cursor1 = deepcopy(src.cursor) + State.selection1 = deepcopy(src.selection) patch(State.lines, event.before, event.after) -- invalidate various cached bits of lines State.lines.current_drawing = nil diff --git a/source_select.lua b/source_select.lua new file mode 100644 index 0000000..297a7bc --- /dev/null +++ b/source_select.lua @@ -0,0 +1,183 @@ +-- helpers for selecting portions of text +-- To keep things simple, we'll ignore the B side when selections start on the +-- A side, and stick to within a single B side selections start in. + +-- Return any intersection of the region from State.selection1 to State.cursor1 (or +-- current mouse, if mouse is pressed; or recent mouse if mouse is pressed and +-- currently over a drawing) with the region between {line=line_index, pos=apos} +-- and {line=line_index, pos=bpos}. +-- apos must be less than bpos. However State.selection1 and State.cursor1 can be in any order. +-- Result: positions spos,epos between apos,bpos. +function Text.clip_selection(State, line_index, apos, bpos) + if State.selection1.line == nil then return nil,nil end + -- min,max = sorted(State.selection1,State.cursor1) + local minl,minp = State.selection1.line,State.selection1.pos + local maxl,maxp + if App.mouse_down(1) then + maxl,maxp = Text.mouse_pos(State) + else + maxl,maxp = State.cursor1.line,State.cursor1.pos + end + if Text.lt1({line=maxl, pos=maxp}, + {line=minl, pos=minp}) then + minl,maxl = maxl,minl + minp,maxp = maxp,minp + end + -- check if intervals are disjoint + if line_index < minl then return nil,nil end + if line_index > maxl then return nil,nil end + if line_index == minl and bpos <= minp then return nil,nil end + if line_index == maxl and apos >= maxp then return nil,nil end + -- compare bounds more carefully (start inclusive, end exclusive) + local a_ge = Text.le1({line=minl, pos=minp}, {line=line_index, pos=apos}) + local b_lt = Text.lt1({line=line_index, pos=bpos}, {line=maxl, pos=maxp}) +--? print(minl,line_index,maxl, '--', minp,apos,bpos,maxp, '--', a_ge,b_lt) + if a_ge and b_lt then + -- fully contained + return apos,bpos + elseif a_ge then + assert(maxl == line_index) + return apos,maxp + elseif b_lt then + assert(minl == line_index) + return minp,bpos + else + assert(minl == maxl and minl == line_index) + return minp,maxp + end +end + +-- draw highlight for line corresponding to (lo,hi) given an approximate x,y and pos on the same screen line +-- Creates text objects every time, so use this sparingly. +-- Returns some intermediate computation useful elsewhere. +function Text.draw_highlight(State, line, x,y, pos, lo,hi) + if lo then + local lo_offset = Text.offset(line.data, lo) + local hi_offset = Text.offset(line.data, hi) + local pos_offset = Text.offset(line.data, pos) + local lo_px + if pos == lo then + lo_px = 0 + else + local before = line.data:sub(pos_offset, lo_offset-1) + local before_text = App.newText(love.graphics.getFont(), before) + lo_px = App.width(before_text) + end +--? print(lo,pos,hi, '--', lo_offset,pos_offset,hi_offset, '--', lo_px) + local s = line.data:sub(lo_offset, hi_offset-1) + local text = App.newText(love.graphics.getFont(), s) + local text_width = App.width(text) + App.color(Highlight_color) + love.graphics.rectangle('fill', x+lo_px,y, text_width,State.line_height) + App.color(Text_color) + return lo_px + end +end + +-- inefficient for some reason, so don't do it on every frame +function Text.mouse_pos(State) + local time = love.timer.getTime() + if State.recent_mouse.time and State.recent_mouse.time > time-0.1 then + return State.recent_mouse.line, State.recent_mouse.pos + end + State.recent_mouse.time = time + local line,pos = Text.to_pos(State, App.mouse_x(), App.mouse_y()) + if line then + State.recent_mouse.line = line + State.recent_mouse.pos = pos + end + return State.recent_mouse.line, State.recent_mouse.pos +end + +function Text.to_pos(State, x,y) + for line_index,line in ipairs(State.lines) do + if line.mode == 'text' then + if Text.in_line(State, line_index, x,y) then + return line_index, Text.to_pos_on_line(State, line_index, x,y) + end + end + end +end + +function Text.cut_selection(State) + if State.selection1.line == nil then return end + local result = Text.selection(State) + Text.delete_selection(State) + return result +end + +function Text.delete_selection(State) + if State.selection1.line == nil then return end + local minl,maxl = minmax(State.selection1.line, State.cursor1.line) + local before = snapshot(State, minl, maxl) + Text.delete_selection_without_undo(State) + record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)}) +end + +function Text.delete_selection_without_undo(State) + if State.selection1.line == nil then return end + -- min,max = sorted(State.selection1,State.cursor1) + local minl,minp = State.selection1.line,State.selection1.pos + local maxl,maxp = State.cursor1.line,State.cursor1.pos + if minl > maxl then + minl,maxl = maxl,minl + minp,maxp = maxp,minp + elseif minl == maxl then + if minp > maxp then + minp,maxp = maxp,minp + end + end + -- update State.cursor1 and State.selection1 + State.cursor1.line = minl + State.cursor1.pos = minp + if Text.lt1(State.cursor1, State.screen_top1) then + State.screen_top1.line = State.cursor1.line + State.screen_top1.pos = Text.pos_at_start_of_screen_line(State, State.cursor1) + end + State.selection1 = {} + -- delete everything between min (inclusive) and max (exclusive) + Text.clear_screen_line_cache(State, minl) + local min_offset = Text.offset(State.lines[minl].data, minp) + local max_offset = Text.offset(State.lines[maxl].data, maxp) + if minl == maxl then +--? print('minl == maxl') + State.lines[minl].data = State.lines[minl].data:sub(1, min_offset-1)..State.lines[minl].data:sub(max_offset) + return + end + assert(minl < maxl) + local rhs = State.lines[maxl].data:sub(max_offset) + for i=maxl,minl+1,-1 do + table.remove(State.lines, i) + table.remove(State.line_cache, i) + end + State.lines[minl].data = State.lines[minl].data:sub(1, min_offset-1)..rhs +end + +function Text.selection(State) + if State.selection1.line == nil then return end + -- min,max = sorted(State.selection1,State.cursor1) + local minl,minp = State.selection1.line,State.selection1.pos + local maxl,maxp = State.cursor1.line,State.cursor1.pos + if minl > maxl then + minl,maxl = maxl,minl + minp,maxp = maxp,minp + elseif minl == maxl then + if minp > maxp then + minp,maxp = maxp,minp + end + end + local min_offset = Text.offset(State.lines[minl].data, minp) + local max_offset = Text.offset(State.lines[maxl].data, maxp) + if minl == maxl then + return State.lines[minl].data:sub(min_offset, max_offset-1) + end + assert(minl < maxl) + local result = {State.lines[minl].data:sub(min_offset)} + for i=minl+1,maxl-1 do + if State.lines[i].mode == 'text' then + table.insert(result, State.lines[i].data) + end + end + table.insert(result, State.lines[maxl].data:sub(1, max_offset-1)) + return table.concat(result, '\n') +end diff --git a/source_text.lua b/source_text.lua index 733b25a..5959fd5 100644 --- a/source_text.lua +++ b/source_text.lua @@ -110,6 +110,10 @@ function Text.draw_wrapping_line(State, line_index, x,y, startpos) screen_line_starting_pos = pos x = State.left end + if State.selection1.line then + local lo, hi = Text.clip_selection(State, line_index, pos, pos+frag_len) + Text.draw_highlight(State, line, x,y, pos, lo,hi) + end -- Make [[WikiWords]] (single word, all in one screen line) clickable. local trimmed_word = rtrim(frag) -- compute_fragments puts whitespace at the end if starts_with(trimmed_word, '[[') and ends_with(trimmed_word, ']]') then @@ -172,6 +176,10 @@ function Text.draw_wrapping_lineB(State, line_index, x,y, startpos) screen_line_starting_pos = pos x = State.left end + if State.selection1.line then + local lo, hi = Text.clip_selection(State, line_index, pos, pos+frag_len) + Text.draw_highlight(State, line, x,y, pos, lo,hi) + end App.screen.draw(frag_text, x,y) -- render cursor if necessary if State.cursor1.posB and line_index == State.cursor1.line then @@ -375,12 +383,13 @@ end -- Don't handle any keys here that would trigger love.textinput above. function Text.keychord_pressed(State, chord) ---? print('chord', chord) +--? print('chord', chord, State.selection1.line, State.selection1.pos) --== shortcuts that mutate text if chord == 'return' then local before_line = State.cursor1.line local before = snapshot(State, before_line) Text.insert_return(State) + State.selection1 = {} if State.cursor_y > App.screen.height - State.line_height then Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right) end @@ -398,6 +407,11 @@ function Text.keychord_pressed(State, chord) schedule_save(State) record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)}) elseif chord == 'backspace' then + if State.selection1.line then + Text.delete_selection(State, State.left, State.right) + schedule_save(State) + return + end local before if State.cursor1.pos and State.cursor1.pos > 1 then before = snapshot(State, State.cursor1.line) @@ -456,6 +470,11 @@ function Text.keychord_pressed(State, chord) schedule_save(State) record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)}) elseif chord == 'delete' then + if State.selection1.line then + Text.delete_selection(State, State.left, State.right) + schedule_save(State) + return + end 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) @@ -504,44 +523,84 @@ function Text.keychord_pressed(State, chord) --== shortcuts that move the cursor elseif chord == 'left' then Text.left(State) + State.selection1 = {} elseif chord == 'right' then Text.right(State) + State.selection1 = {} elseif chord == 'S-left' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.left(State) elseif chord == 'S-right' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.right(State) -- C- hotkeys reserved for drawings, so we'll use M- elseif chord == 'M-left' then Text.word_left(State) + State.selection1 = {} elseif chord == 'M-right' then Text.word_right(State) + State.selection1 = {} elseif chord == 'M-S-left' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.word_left(State) elseif chord == 'M-S-right' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.word_right(State) elseif chord == 'home' then Text.start_of_line(State) + State.selection1 = {} elseif chord == 'end' then Text.end_of_line(State) + State.selection1 = {} elseif chord == 'S-home' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.start_of_line(State) elseif chord == 'S-end' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.end_of_line(State) elseif chord == 'up' then Text.up(State) + State.selection1 = {} elseif chord == 'down' then Text.down(State) + State.selection1 = {} elseif chord == 'S-up' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.up(State) elseif chord == 'S-down' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.down(State) elseif chord == 'pageup' then Text.pageup(State) + State.selection1 = {} elseif chord == 'pagedown' then Text.pagedown(State) + State.selection1 = {} elseif chord == 'S-pageup' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.pageup(State) elseif chord == 'S-pagedown' then + if State.selection1.line == nil then + State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} + end Text.pagedown(State) end end diff --git a/source_text_tests.lua b/source_text_tests.lua index 89ad1ce..5cd0f02 100644 --- a/source_text_tests.lua +++ b/source_text_tests.lua @@ -282,6 +282,7 @@ function test_click_with_mouse() 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') + check_nil(Editor_state.selection1.line, 'F - test_click_with_mouse/selection is empty to avoid perturbing future edits') end function test_click_with_mouse_to_left_of_line() @@ -300,6 +301,7 @@ function test_click_with_mouse_to_left_of_line() -- 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') + check_nil(Editor_state.selection1.line, 'F - test_click_with_mouse_to_left_of_line/selection is empty to avoid perturbing future edits') end function test_click_with_mouse_takes_margins_into_account() @@ -319,6 +321,7 @@ function test_click_with_mouse_takes_margins_into_account() -- 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') + check_nil(Editor_state.selection1.line, 'F - test_click_with_mouse_takes_margins_into_account/selection is empty to avoid perturbing future edits') end function test_click_with_mouse_on_empty_line() @@ -408,6 +411,7 @@ function test_click_with_mouse_on_wrapping_line() -- 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') + check_nil(Editor_state.selection1.line, 'F - test_click_with_mouse_on_wrapping_line/selection is empty to avoid perturbing future edits') end function test_click_with_mouse_on_wrapping_line_takes_margins_into_account() @@ -427,6 +431,7 @@ function test_click_with_mouse_on_wrapping_line_takes_margins_into_account() -- 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') + check_nil(Editor_state.selection1.line, 'F - test_click_with_mouse_on_wrapping_line_takes_margins_into_account/selection is empty to avoid perturbing future edits') end function test_draw_text_wrapping_within_word() @@ -585,6 +590,174 @@ function test_click_past_end_of_word_wrapping_line() check_eq(Editor_state.cursor1.pos, 20, 'F - test_click_past_end_of_word_wrapping_line/cursor') end +function test_select_text() + io.write('\ntest_select_text') + -- display a line of text + App.screen.init{width=75, height=80} + 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} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- select a letter + App.fake_key_press('lshift') + edit.run_after_keychord(Editor_state, 'S-right') + App.fake_key_release('lshift') + edit.key_released(Editor_state, 'lshift') + -- selection persists even after shift is released + check_eq(Editor_state.selection1.line, 1, 'F - test_select_text/selection:line') + check_eq(Editor_state.selection1.pos, 1, 'F - test_select_text/selection:pos') + check_eq(Editor_state.cursor1.line, 1, 'F - test_select_text/cursor:line') + check_eq(Editor_state.cursor1.pos, 2, 'F - test_select_text/cursor:pos') +end + +function test_cursor_movement_without_shift_resets_selection() + io.write('\ntest_cursor_movement_without_shift_resets_selection') + -- display a line of text with some part selected + App.screen.init{width=75, 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=1} + Editor_state.selection1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- press an arrow key without shift + edit.run_after_keychord(Editor_state, 'right') + -- no change to data, selection is reset + check_nil(Editor_state.selection1.line, 'F - test_cursor_movement_without_shift_resets_selection') + check_eq(Editor_state.lines[1].data, 'abc', 'F - test_cursor_movement_without_shift_resets_selection/data') +end + +function test_edit_deletes_selection() + io.write('\ntest_edit_deletes_selection') + -- display a line of text with some part selected + App.screen.init{width=75, 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=1} + Editor_state.selection1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- press a key + edit.run_after_textinput(Editor_state, 'x') + -- selected text is deleted and replaced with the key + check_eq(Editor_state.lines[1].data, 'xbc', 'F - test_edit_deletes_selection') +end + +function test_edit_with_shift_key_deletes_selection() + io.write('\ntest_edit_with_shift_key_deletes_selection') + -- display a line of text with some part selected + App.screen.init{width=75, 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=1} + Editor_state.selection1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- mimic precise keypresses for a capital letter + App.fake_key_press('lshift') + edit.keychord_pressed(Editor_state, 'd', 'd') + edit.textinput(Editor_state, 'D') + edit.key_released(Editor_state, 'd') + App.fake_key_release('lshift') + -- selected text is deleted and replaced with the key + check_nil(Editor_state.selection1.line, 'F - test_edit_with_shift_key_deletes_selection') + check_eq(Editor_state.lines[1].data, 'Dbc', 'F - test_edit_with_shift_key_deletes_selection/data') +end + +function test_copy_does_not_reset_selection() + io.write('\ntest_copy_does_not_reset_selection') + -- display a line of text with a selection + App.screen.init{width=75, 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=1} + Editor_state.selection1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- copy selection + edit.run_after_keychord(Editor_state, 'C-c') + check_eq(App.clipboard, 'a', 'F - test_copy_does_not_reset_selection/clipboard') + -- selection is reset since shift key is not pressed + check(Editor_state.selection1.line, 'F - test_copy_does_not_reset_selection') +end + +function test_cut() + io.write('\ntest_cut') + -- display a line of text with some part selected + App.screen.init{width=75, 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=1} + Editor_state.selection1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- press a key + edit.run_after_keychord(Editor_state, 'C-x') + check_eq(App.clipboard, 'a', 'F - test_cut/clipboard') + -- selected text is deleted + check_eq(Editor_state.lines[1].data, 'bc', 'F - test_cut/data') +end + +function test_paste_replaces_selection() + io.write('\ntest_paste_replaces_selection') + -- display a line of text with a selection + App.screen.init{width=75, 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.selection1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- set clipboard + App.clipboard = 'xyz' + -- paste selection + edit.run_after_keychord(Editor_state, 'C-v') + -- selection is reset since shift key is not pressed + -- selection includes the newline, so it's also deleted + check_eq(Editor_state.lines[1].data, 'xyzdef', 'F - test_paste_replaces_selection') +end + +function test_deleting_selection_may_scroll() + io.write('\ntest_deleting_selection_may_scroll') + -- display lines 2/3/4 + 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=2} + 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_deleting_selection_may_scroll/baseline/screen:1') + y = y + Editor_state.line_height + App.screen.check(y, 'ghi', 'F - test_deleting_selection_may_scroll/baseline/screen:2') + y = y + Editor_state.line_height + App.screen.check(y, 'jkl', 'F - test_deleting_selection_may_scroll/baseline/screen:3') + -- set up a selection starting above the currently displayed page + Editor_state.selection1 = {line=1, pos=2} + -- delete selection + edit.run_after_keychord(Editor_state, 'backspace') + -- page scrolls up + check_eq(Editor_state.screen_top1.line, 1, 'F - test_deleting_selection_may_scroll') + check_eq(Editor_state.lines[1].data, 'ahi', 'F - test_deleting_selection_may_scroll/data') +end + function test_edit_wrapping_text() io.write('\ntest_edit_wrapping_text') App.screen.init{width=50, height=60} @@ -692,10 +865,108 @@ function test_move_cursor_using_mouse() Editor_state.cursor1 = {line=1, pos=1} Editor_state.screen_top1 = {line=1, pos=1} Editor_state.screen_bottom1 = {} + Editor_state.selection1 = {} 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) + edit.run_after_mouse_release(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') + check_nil(Editor_state.selection1.line, 'F - test_move_cursor_using_mouse/selection:line') + check_nil(Editor_state.selection1.pos, 'F - test_move_cursor_using_mouse/selection:pos') +end + +function test_select_text_using_mouse() + io.write('\ntest_select_text_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 = {} + Editor_state.selection1 = {} + edit.draw(Editor_state) -- populate line_cache.starty for each line Editor_state.line_cache + -- press and hold on first location + edit.run_after_mouse_press(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + -- drag and release somewhere else + edit.run_after_mouse_release(Editor_state, Editor_state.left+20,Editor_state.top+Editor_state.line_height+5, 1) + check_eq(Editor_state.selection1.line, 1, 'F - test_select_text_using_mouse/selection:line') + check_eq(Editor_state.selection1.pos, 2, 'F - test_select_text_using_mouse/selection:pos') + check_eq(Editor_state.cursor1.line, 2, 'F - test_select_text_using_mouse/cursor:line') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_select_text_using_mouse/cursor:pos') +end + +function test_select_text_using_mouse_and_shift() + io.write('\ntest_select_text_using_mouse_and_shift') + 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 = {} + Editor_state.selection1 = {} + edit.draw(Editor_state) -- populate line_cache.starty for each line Editor_state.line_cache + -- click on first location + edit.run_after_mouse_press(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + edit.run_after_mouse_release(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + -- hold down shift and click somewhere else + App.fake_key_press('lshift') + edit.run_after_mouse_press(Editor_state, Editor_state.left+20,Editor_state.top+5, 1) + edit.run_after_mouse_release(Editor_state, Editor_state.left+20,Editor_state.top+Editor_state.line_height+5, 1) + App.fake_key_release('lshift') + check_eq(Editor_state.selection1.line, 1, 'F - test_select_text_using_mouse_and_shift/selection:line') + check_eq(Editor_state.selection1.pos, 2, 'F - test_select_text_using_mouse_and_shift/selection:pos') + check_eq(Editor_state.cursor1.line, 2, 'F - test_select_text_using_mouse_and_shift/cursor:line') + check_eq(Editor_state.cursor1.pos, 4, 'F - test_select_text_using_mouse_and_shift/cursor:pos') +end + +function test_select_text_repeatedly_using_mouse_and_shift() + io.write('\ntest_select_text_repeatedly_using_mouse_and_shift') + 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) + Text.redraw_all(Editor_state) + Editor_state.cursor1 = {line=1, pos=1} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + Editor_state.selection1 = {} + edit.draw(Editor_state) -- populate line_cache.starty for each line Editor_state.line_cache + -- click on first location + edit.run_after_mouse_press(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + edit.run_after_mouse_release(Editor_state, Editor_state.left+8,Editor_state.top+5, 1) + -- hold down shift and click on a second location + App.fake_key_press('lshift') + edit.run_after_mouse_press(Editor_state, Editor_state.left+20,Editor_state.top+5, 1) + edit.run_after_mouse_release(Editor_state, Editor_state.left+20,Editor_state.top+Editor_state.line_height+5, 1) + -- hold down shift and click at a third location + App.fake_key_press('lshift') + edit.run_after_mouse_press(Editor_state, Editor_state.left+20,Editor_state.top+5, 1) + edit.run_after_mouse_release(Editor_state, Editor_state.left+8,Editor_state.top+Editor_state.line_height+5, 1) + App.fake_key_release('lshift') + -- selection is between first and third location. forget the second location, not the first. + check_eq(Editor_state.selection1.line, 1, 'F - test_select_text_repeatedly_using_mouse_and_shift/selection:line') + check_eq(Editor_state.selection1.pos, 2, 'F - test_select_text_repeatedly_using_mouse_and_shift/selection:pos') + check_eq(Editor_state.cursor1.line, 2, 'F - test_select_text_repeatedly_using_mouse_and_shift/cursor:line') + check_eq(Editor_state.cursor1.pos, 2, 'F - test_select_text_repeatedly_using_mouse_and_shift/cursor:pos') +end + +function test_cut_without_selection() + io.write('\ntest_cut_without_selection') + -- 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 = {} + Editor_state.selection1 = {} + edit.draw(Editor_state) + -- try to cut without selecting text + edit.run_after_keychord(Editor_state, 'C-x') + -- no crash + check_nil(Editor_state.selection1.line, 'F - test_cut_without_selection') end function test_pagedown() @@ -1440,7 +1711,7 @@ function test_position_cursor_on_recently_edited_wrapping_line() 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) + edit.run_after_mouse_release(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') @@ -1517,6 +1788,107 @@ function test_backspace_past_line_boundary() check_eq(Editor_state.lines[1].data, 'abcdef', "F - test_backspace_past_line_boundary") end +-- some tests for operating over selections created using Shift- chords +-- we're just testing delete_selection, and it works the same for all keys + +function test_backspace_over_selection() + io.write('\ntest_backspace_over_selection') + -- select just one character within a line with cursor before selection + 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=1, pos=1} + Editor_state.selection1 = {line=1, pos=2} + -- backspace deletes the selected character, even though it's after the cursor + edit.run_after_keychord(Editor_state, 'backspace') + check_eq(Editor_state.lines[1].data, 'bc', "F - test_backspace_over_selection/data") + -- cursor (remains) at start of selection + check_eq(Editor_state.cursor1.line, 1, "F - test_backspace_over_selection/cursor:line") + check_eq(Editor_state.cursor1.pos, 1, "F - test_backspace_over_selection/cursor:pos") + -- selection is cleared + check_nil(Editor_state.selection1.line, "F - test_backspace_over_selection/selection") +end + +function test_backspace_over_selection_reverse() + io.write('\ntest_backspace_over_selection_reverse') + -- select just one character within a line with cursor after selection + 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=1, pos=2} + Editor_state.selection1 = {line=1, pos=1} + -- backspace deletes the selected character + edit.run_after_keychord(Editor_state, 'backspace') + check_eq(Editor_state.lines[1].data, 'bc', "F - test_backspace_over_selection_reverse/data") + -- cursor moves to start of selection + check_eq(Editor_state.cursor1.line, 1, "F - test_backspace_over_selection_reverse/cursor:line") + check_eq(Editor_state.cursor1.pos, 1, "F - test_backspace_over_selection_reverse/cursor:pos") + -- selection is cleared + check_nil(Editor_state.selection1.line, "F - test_backspace_over_selection_reverse/selection") +end + +function test_backspace_over_multiple_lines() + io.write('\ntest_backspace_over_multiple_lines') + -- select just one character within a line with cursor after selection + 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=1, pos=2} + Editor_state.selection1 = {line=4, pos=2} + -- backspace deletes the region and joins the remaining portions of lines on either side + edit.run_after_keychord(Editor_state, 'backspace') + check_eq(Editor_state.lines[1].data, 'akl', "F - test_backspace_over_multiple_lines/data:1") + check_eq(Editor_state.lines[2].data, 'mno', "F - test_backspace_over_multiple_lines/data:2") + -- cursor remains at start of selection + check_eq(Editor_state.cursor1.line, 1, "F - test_backspace_over_multiple_lines/cursor:line") + check_eq(Editor_state.cursor1.pos, 2, "F - test_backspace_over_multiple_lines/cursor:pos") + -- selection is cleared + check_nil(Editor_state.selection1.line, "F - test_backspace_over_multiple_lines/selection") +end + +function test_backspace_to_end_of_line() + io.write('\ntest_backspace_to_end_of_line') + -- select region from cursor to end of 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=1, pos=2} + Editor_state.selection1 = {line=1, pos=4} + -- backspace deletes rest of line without joining to any other line + edit.run_after_keychord(Editor_state, 'backspace') + check_eq(Editor_state.lines[1].data, 'a', "F - test_backspace_to_start_of_line/data:1") + check_eq(Editor_state.lines[2].data, 'def', "F - test_backspace_to_start_of_line/data:2") + -- cursor remains at start of selection + check_eq(Editor_state.cursor1.line, 1, "F - test_backspace_to_start_of_line/cursor:line") + check_eq(Editor_state.cursor1.pos, 2, "F - test_backspace_to_start_of_line/cursor:pos") + -- selection is cleared + check_nil(Editor_state.selection1.line, "F - test_backspace_to_start_of_line/selection") +end + +function test_backspace_to_start_of_line() + io.write('\ntest_backspace_to_start_of_line') + -- select region from cursor to start of 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.selection1 = {line=2, pos=3} + -- backspace deletes beginning of line without joining to any other line + edit.run_after_keychord(Editor_state, 'backspace') + check_eq(Editor_state.lines[1].data, 'abc', "F - test_backspace_to_start_of_line/data:1") + check_eq(Editor_state.lines[2].data, 'f', "F - test_backspace_to_start_of_line/data:2") + -- cursor remains at start of selection + check_eq(Editor_state.cursor1.line, 2, "F - test_backspace_to_start_of_line/cursor:line") + check_eq(Editor_state.cursor1.pos, 1, "F - test_backspace_to_start_of_line/cursor:pos") + -- selection is cleared + check_nil(Editor_state.selection1.line, "F - test_backspace_to_start_of_line/selection") +end + function test_undo_insert_text() io.write('\ntest_undo_insert_text') App.screen.init{width=120, height=60} @@ -1531,6 +1903,8 @@ function test_undo_insert_text() 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') + check_nil(Editor_state.selection1.line, 'F - test_undo_insert_text/baseline/selection:line') + check_nil(Editor_state.selection1.pos, 'F - test_undo_insert_text/baseline/selection: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 @@ -1541,6 +1915,8 @@ function test_undo_insert_text() 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') + check_nil(Editor_state.selection1.line, 'F - test_undo_insert_text/selection:line') + check_nil(Editor_state.selection1.pos, 'F - test_undo_insert_text/selection:pos') y = Editor_state.top App.screen.check(y, 'abc', 'F - test_undo_insert_text/screen:1') y = y + Editor_state.line_height @@ -1562,6 +1938,8 @@ function test_undo_delete_text() 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') + check_nil(Editor_state.selection1.line, 'F - test_undo_delete_text/baseline/selection:line') + check_nil(Editor_state.selection1.pos, 'F - test_undo_delete_text/baseline/selection: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 @@ -1573,6 +1951,10 @@ function test_undo_delete_text() 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') + check_nil(Editor_state.selection1.line, 'F - test_undo_delete_text/selection:line') + check_nil(Editor_state.selection1.pos, 'F - test_undo_delete_text/selection:pos') +--? check_eq(Editor_state.selection1.line, 2, 'F - test_undo_delete_text/selection:line') +--? check_eq(Editor_state.selection1.pos, 4, 'F - test_undo_delete_text/selection:pos') y = Editor_state.top App.screen.check(y, 'abc', 'F - test_undo_delete_text/screen:1') y = y + Editor_state.line_height @@ -1581,6 +1963,30 @@ function test_undo_delete_text() App.screen.check(y, 'xyz', 'F - test_undo_delete_text/screen:3') end +function test_undo_restores_selection() + io.write('\ntest_undo_restores_selection') + -- display a line of text with some part selected + App.screen.init{width=75, 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=1} + Editor_state.selection1 = {line=1, pos=2} + Editor_state.screen_top1 = {line=1, pos=1} + Editor_state.screen_bottom1 = {} + edit.draw(Editor_state) + -- delete selected text + edit.run_after_textinput(Editor_state, 'x') + check_eq(Editor_state.lines[1].data, 'xbc', 'F - test_undo_restores_selection/baseline') + check_nil(Editor_state.selection1.line, 'F - test_undo_restores_selection/baseline:selection') + -- undo + edit.run_after_keychord(Editor_state, 'C-z') + edit.run_after_keychord(Editor_state, 'C-z') + -- selection is restored + check_eq(Editor_state.selection1.line, 1, 'F - test_undo_restores_selection/line') + check_eq(Editor_state.selection1.pos, 2, 'F - test_undo_restores_selection/pos') +end + function test_search() io.write('\ntest_search') App.screen.init{width=120, height=60} diff --git a/text.lua b/text.lua index 6be260d..9720bfa 100644 --- a/text.lua +++ b/text.lua @@ -931,7 +931,6 @@ end -- resize helper function Text.tweak_screen_top_and_cursor(State) ---? print('a', State.selection1.line) 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]