diff --git a/lib/reline/key_actor/emacs.rb b/lib/reline/key_actor/emacs.rb index 9c797ba43e..edd88289a3 100644 --- a/lib/reline/key_actor/emacs.rb +++ b/lib/reline/key_actor/emacs.rb @@ -63,7 +63,7 @@ class Reline::KeyActor::Emacs < Reline::KeyActor::Base # 30 ^^ :ed_unassigned, # 31 ^_ - :ed_unassigned, + :undo, # 32 SPACE :ed_insert, # 33 ! diff --git a/lib/reline/line_editor.rb b/lib/reline/line_editor.rb index 4c76932c10..23ece60220 100644 --- a/lib/reline/line_editor.rb +++ b/lib/reline/line_editor.rb @@ -4,7 +4,6 @@ require 'tempfile' class Reline::LineEditor - # TODO: undo # TODO: Use "private alias_method" idiom after drop Ruby 2.5. attr_reader :byte_pointer attr_accessor :confirm_multiline_termination_proc @@ -251,6 +250,8 @@ def reset_variables(prompt = '', encoding:) @resized = false @cache = {} @rendered_screen = RenderedScreen.new(base_y: 0, lines: [], cursor_y: 0) + @past_lines = [] + @undoing = false reset_line end @@ -948,7 +949,8 @@ def dialog_proc_scope_completion_journey_data unless @waiting_proc byte_pointer_diff = @byte_pointer - old_byte_pointer @byte_pointer = old_byte_pointer - send(@vi_waiting_operator, byte_pointer_diff) + method_obj = method(@vi_waiting_operator) + wrap_method_call(@vi_waiting_operator, method_obj, byte_pointer_diff) cleanup_waiting end else @@ -1009,7 +1011,8 @@ def wrap_method_call(method_symbol, method_obj, key, with_operator = false) if @vi_waiting_operator byte_pointer_diff = @byte_pointer - old_byte_pointer @byte_pointer = old_byte_pointer - send(@vi_waiting_operator, byte_pointer_diff) + method_obj = method(@vi_waiting_operator) + wrap_method_call(@vi_waiting_operator, method_obj, byte_pointer_diff) cleanup_waiting end @kill_ring.process @@ -1106,6 +1109,7 @@ def update(key) end def input_key(key) + save_old_buffer @config.reset_oneshot_key_bindings @dialogs.each do |dialog| if key.char.instance_of?(Symbol) and key.char == dialog.name @@ -1120,7 +1124,6 @@ def input_key(key) finish return end - old_lines = @buffer_of_lines.dup @first_char = false @completion_occurs = false @@ -1134,12 +1137,15 @@ def input_key(key) @completion_journey_state = nil end + push_past_lines unless @undoing + @undoing = false + if @in_pasting clear_dialogs return end - modified = old_lines != @buffer_of_lines + modified = @old_buffer_of_lines != @buffer_of_lines if !@completion_occurs && modified && !@config.disable_completion && @config.autocompletion # Auto complete starts only when edited process_insert(force: true) @@ -1148,6 +1154,26 @@ def input_key(key) modified end + def save_old_buffer + @old_buffer_of_lines = @buffer_of_lines.dup + @old_byte_pointer = @byte_pointer.dup + @old_line_index = @line_index.dup + end + + def push_past_lines + if @old_buffer_of_lines != @buffer_of_lines + @past_lines.push([@old_buffer_of_lines, @old_byte_pointer, @old_line_index]) + end + trim_past_lines + end + + MAX_PAST_LINES = 100 + def trim_past_lines + if @past_lines.size > MAX_PAST_LINES + @past_lines.shift + end + end + def scroll_into_view _wrapped_cursor_x, wrapped_cursor_y = wrapped_cursor_position if wrapped_cursor_y < screen_scroll_top @@ -1224,6 +1250,18 @@ def set_current_line(line, byte_pointer = nil) process_auto_indent end + def set_current_lines(lines, byte_pointer = nil, line_index = 0) + cursor = current_byte_pointer_cursor + @buffer_of_lines = lines + @line_index = line_index + if byte_pointer + @byte_pointer = byte_pointer + else + calculate_nearest_cursor(cursor) + end + process_auto_indent + end + def retrieve_completion_block(set_completion_quote_character = false) if Reline.completer_word_break_characters.empty? word_break_regexp = nil @@ -1306,6 +1344,7 @@ def confirm_multiline_termination end def insert_pasted_text(text) + save_old_buffer pre = @buffer_of_lines[@line_index].byteslice(0, @byte_pointer) post = @buffer_of_lines[@line_index].byteslice(@byte_pointer..) lines = (pre + text.gsub(/\r\n?/, "\n") + post).split("\n", -1) @@ -1313,6 +1352,7 @@ def insert_pasted_text(text) @buffer_of_lines[@line_index, 1] = lines @line_index += lines.size - 1 @byte_pointer = @buffer_of_lines[@line_index].bytesize - post.bytesize + push_past_lines end def insert_text(text) @@ -2487,4 +2527,15 @@ def finish private def vi_editing_mode(key) @config.editing_mode = :vi_insert end + + private def undo(_key) + return if @past_lines.empty? + + @undoing = true + + target_lines, target_cursor_x, target_cursor_y = @past_lines.last + set_current_lines(target_lines, target_cursor_x, target_cursor_y) + + @past_lines.pop + end end diff --git a/test/reline/test_key_actor_emacs.rb b/test/reline/test_key_actor_emacs.rb index 409a7334cb..013ca2f7b3 100644 --- a/test/reline/test_key_actor_emacs.rb +++ b/test/reline/test_key_actor_emacs.rb @@ -1437,4 +1437,72 @@ def test_vi_editing_mode @line_editor.__send__(:vi_editing_mode, nil) assert(@config.editing_mode_is?(:vi_insert)) end + + def test_undo + input_keys("\C-_", false) + assert_line_around_cursor('', '') + input_keys("aあb\C-h\C-h\C-h", false) + assert_line_around_cursor('', '') + input_keys("\C-_", false) + assert_line_around_cursor('a', '') + input_keys("\C-_", false) + assert_line_around_cursor('aあ', '') + input_keys("\C-_", false) + assert_line_around_cursor('aあb', '') + input_keys("\C-_", false) + assert_line_around_cursor('aあ', '') + input_keys("\C-_", false) + assert_line_around_cursor('a', '') + input_keys("\C-_", false) + assert_line_around_cursor('', '') + end + + def test_undo_with_cursor_position + input_keys("abc\C-b\C-h", false) + assert_line_around_cursor('a', 'c') + input_keys("\C-_", false) + assert_line_around_cursor('ab', 'c') + input_keys("あいう\C-b\C-h", false) + assert_line_around_cursor('abあ', 'うc') + input_keys("\C-_", false) + assert_line_around_cursor('abあい', 'うc') + end + + def test_undo_with_multiline + @line_editor.multiline_on + @line_editor.confirm_multiline_termination_proc = proc {} + input_keys("1\n2\n3", false) + assert_whole_lines(["1", "2", "3"]) + assert_line_index(2) + assert_line_around_cursor('3', '') + input_keys("\C-p\C-h\C-h", false) + assert_whole_lines(["1", "3"]) + assert_line_index(0) + assert_line_around_cursor('1', '') + input_keys("\C-_", false) + assert_whole_lines(["1", "", "3"]) + assert_line_index(1) + assert_line_around_cursor('', '') + input_keys("\C-_", false) + assert_whole_lines(["1", "2", "3"]) + assert_line_index(1) + assert_line_around_cursor('2', '') + input_keys("\C-_", false) + assert_whole_lines(["1", "2", ""]) + assert_line_index(2) + assert_line_around_cursor('', '') + input_keys("\C-_", false) + assert_whole_lines(["1", "2"]) + assert_line_index(1) + assert_line_around_cursor('2', '') + end + + def test_undo_with_many_times + str = "a" + "b" * 100 + input_keys(str, false) + 100.times { input_keys("\C-_", false) } + assert_line_around_cursor('a', '') + input_keys("\C-_", false) + assert_line_around_cursor('a', '') + end end diff --git a/test/reline/yamatanooroti/test_rendering.rb b/test/reline/yamatanooroti/test_rendering.rb index 9e8d7da78f..37a1c1a193 100644 --- a/test/reline/yamatanooroti/test_rendering.rb +++ b/test/reline/yamatanooroti/test_rendering.rb @@ -556,6 +556,19 @@ def test_bracketed_paste EOC end + def test_bracketed_paste_with_undo + omit if Reline.core.io_gate.win? + start_terminal(5, 30, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl}, startup_message: 'Multiline REPL.') + write("abc") + write("\e[200~def hoge\r\t3\rend\e[201~") + write("\C-_") + close + assert_screen(<<~EOC) + Multiline REPL. + prompt> abc + EOC + end + def test_backspace_until_returns_to_initial start_terminal(5, 30, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl}, startup_message: 'Multiline REPL.') write("ABC")