From 14b46188b539adc78bb29f6a9f07f53b32e8a40e Mon Sep 17 00:00:00 2001 From: Mari Imaizumi Date: Wed, 8 May 2024 21:09:30 +0900 Subject: [PATCH 1/7] Refactor send --- lib/reline/line_editor.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/reline/line_editor.rb b/lib/reline/line_editor.rb index 4c76932c10..79a250024c 100644 --- a/lib/reline/line_editor.rb +++ b/lib/reline/line_editor.rb @@ -948,7 +948,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 +1010,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 From 9f09d8fb547a70b2d53e792b630d9c89fa9587d7 Mon Sep 17 00:00:00 2001 From: Mari Imaizumi Date: Fri, 10 May 2024 04:43:01 +0900 Subject: [PATCH 2/7] Implement the undo command --- lib/reline.rb | 2 + lib/reline/key_actor/emacs.rb | 2 +- lib/reline/line_editor.rb | 82 ++++++++++++++++++++- test/reline/test_key_actor_emacs.rb | 57 ++++++++++++++ test/reline/yamatanooroti/test_rendering.rb | 12 +++ 5 files changed, 151 insertions(+), 4 deletions(-) diff --git a/lib/reline.rb b/lib/reline.rb index fb00b96531..61b819fa40 100644 --- a/lib/reline.rb +++ b/lib/reline.rb @@ -353,9 +353,11 @@ def readline(prompt = '', add_hist = false) line_editor.set_pasting_state(io_gate.in_pasting?) inputs.each do |key| if key.char == :bracketed_paste_start + line_editor.save_old_buffer text = io_gate.read_bracketed_paste line_editor.insert_pasted_text(text) line_editor.scroll_into_view + line_editor.save_past_lines else line_editor.update(key) end 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 79a250024c..3b4d377ee7 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 @@ -35,6 +34,31 @@ class Reline::LineEditor vi_end_big_word } + DELETE_COMMANDS = %i{ + backward_delete_char + backward_kill_word + delete_char + ed_delete_next_char + ed_delete_prev_char + ed_delete_prev_word + ed_kill_line + em_delete + em_delete_next_word + em_delete_prev_char + em_kill_line + em_kill_region + kill_line + kill_whole_line + kill_word + unix_line_discard + unix_word_rubout + vi_change_to_eol + vi_delete_meta + vi_delete_meta_confirm + vi_delete_prev_char + vi_kill_line_prev + } + module CompletionState NORMAL = :normal COMPLETION = :completion @@ -251,6 +275,8 @@ def reset_variables(prompt = '', encoding:) @resized = false @cache = {} @rendered_screen = RenderedScreen.new(base_y: 0, lines: [], cursor_y: 0) + @past_lines = [["", 0, 0]] + @using_delete_command = false reset_line end @@ -974,6 +1000,9 @@ def dialog_proc_scope_completion_journey_data end def wrap_method_call(method_symbol, method_obj, key, with_operator = false) + if DELETE_COMMANDS.include?(method_symbol) + @using_delete_command = true + end if @config.editing_mode_is?(:emacs, :vi_insert) and @vi_waiting_operator.nil? not_insertion = method_symbol != :ed_insert process_insert(force: not_insertion) @@ -1108,6 +1137,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 @@ -1122,7 +1152,6 @@ def input_key(key) finish return end - old_lines = @buffer_of_lines.dup @first_char = false @completion_occurs = false @@ -1136,12 +1165,14 @@ def input_key(key) @completion_journey_state = nil end + save_past_lines + 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) @@ -1150,6 +1181,31 @@ 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 + + MAX_PAST_LINES = 100 + def save_past_lines + if @old_buffer_of_lines != @buffer_of_lines + if !@using_delete_command && @buffer_of_lines == @past_lines.last.first + # When deleting, @buffer_of_lines and @past_lines.last.first become the same. + # If it is the same as the previous state, consider it undone and do not add to past_lines. + @past_lines.pop + else + # Save the state before the changes. + @past_lines.push([@old_buffer_of_lines, @old_byte_pointer, @old_line_index]) + end + end + @using_delete_command = false + + 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 @@ -1226,6 +1282,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 @@ -1915,6 +1983,7 @@ def finish private def em_delete_or_list(key) if current_line.empty? or @byte_pointer < current_line.bytesize + @using_delete_command = true em_delete(key) elsif !@config.autocompletion # show completed list result = call_completion_proc @@ -2489,4 +2558,11 @@ def finish private def vi_editing_mode(key) @config.editing_mode = :vi_insert end + + private def undo(_key) + return if @past_lines.empty? + + target_lines, target_cursor_x, target_cursor_y = @past_lines.last + set_current_lines(target_lines, target_cursor_x, target_cursor_y) + end end diff --git a/test/reline/test_key_actor_emacs.rb b/test/reline/test_key_actor_emacs.rb index 409a7334cb..c2c79e068f 100644 --- a/test/reline/test_key_actor_emacs.rb +++ b/test/reline/test_key_actor_emacs.rb @@ -1437,4 +1437,61 @@ 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("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 end diff --git a/test/reline/yamatanooroti/test_rendering.rb b/test/reline/yamatanooroti/test_rendering.rb index 9e8d7da78f..b23701c6a5 100644 --- a/test/reline/yamatanooroti/test_rendering.rb +++ b/test/reline/yamatanooroti/test_rendering.rb @@ -556,6 +556,18 @@ 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("\e[200~def hoge\r\t3\rend\e[201~") + write("\C-_") + close + assert_screen(<<~EOC) + Multiline REPL. + prompt> + 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") From 3f18434c74dbbc150caba3cdce587f881174e2f9 Mon Sep 17 00:00:00 2001 From: Mari Imaizumi Date: Sat, 11 May 2024 23:20:40 +0900 Subject: [PATCH 3/7] Fix @past_lines initialization --- lib/reline/line_editor.rb | 4 ++-- test/reline/test_key_actor_emacs.rb | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/reline/line_editor.rb b/lib/reline/line_editor.rb index 3b4d377ee7..02e46c06be 100644 --- a/lib/reline/line_editor.rb +++ b/lib/reline/line_editor.rb @@ -275,7 +275,7 @@ def reset_variables(prompt = '', encoding:) @resized = false @cache = {} @rendered_screen = RenderedScreen.new(base_y: 0, lines: [], cursor_y: 0) - @past_lines = [["", 0, 0]] + @past_lines = [] @using_delete_command = false reset_line end @@ -1190,7 +1190,7 @@ def save_old_buffer MAX_PAST_LINES = 100 def save_past_lines if @old_buffer_of_lines != @buffer_of_lines - if !@using_delete_command && @buffer_of_lines == @past_lines.last.first + if !@past_lines.empty? && !@using_delete_command && @buffer_of_lines == @past_lines.last.first # When deleting, @buffer_of_lines and @past_lines.last.first become the same. # If it is the same as the previous state, consider it undone and do not add to past_lines. @past_lines.pop diff --git a/test/reline/test_key_actor_emacs.rb b/test/reline/test_key_actor_emacs.rb index c2c79e068f..d79b0a60e8 100644 --- a/test/reline/test_key_actor_emacs.rb +++ b/test/reline/test_key_actor_emacs.rb @@ -1439,6 +1439,8 @@ def test_vi_editing_mode 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) From f794c84339e89ca6e958961ffad3d7f8ab28d83b Mon Sep 17 00:00:00 2001 From: Mari Imaizumi Date: Sat, 11 May 2024 23:27:31 +0900 Subject: [PATCH 4/7] Improve assertion --- test/reline/yamatanooroti/test_rendering.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/reline/yamatanooroti/test_rendering.rb b/test/reline/yamatanooroti/test_rendering.rb index b23701c6a5..37a1c1a193 100644 --- a/test/reline/yamatanooroti/test_rendering.rb +++ b/test/reline/yamatanooroti/test_rendering.rb @@ -559,12 +559,13 @@ def test_bracketed_paste 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> + prompt> abc EOC end From 98907fc55636ffd4fc357d00408601be36322a52 Mon Sep 17 00:00:00 2001 From: Mari Imaizumi Date: Sun, 12 May 2024 00:51:20 +0900 Subject: [PATCH 5/7] Hide to save buffer in insert_pasted_text --- lib/reline.rb | 2 -- lib/reline/line_editor.rb | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/reline.rb b/lib/reline.rb index 61b819fa40..fb00b96531 100644 --- a/lib/reline.rb +++ b/lib/reline.rb @@ -353,11 +353,9 @@ def readline(prompt = '', add_hist = false) line_editor.set_pasting_state(io_gate.in_pasting?) inputs.each do |key| if key.char == :bracketed_paste_start - line_editor.save_old_buffer text = io_gate.read_bracketed_paste line_editor.insert_pasted_text(text) line_editor.scroll_into_view - line_editor.save_past_lines else line_editor.update(key) end diff --git a/lib/reline/line_editor.rb b/lib/reline/line_editor.rb index 02e46c06be..1b054a91b4 100644 --- a/lib/reline/line_editor.rb +++ b/lib/reline/line_editor.rb @@ -1376,6 +1376,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) @@ -1383,6 +1384,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 + save_past_lines end def insert_text(text) From 9a19266eb0dcab2319ddbc5fa4207a9cd2d4969f Mon Sep 17 00:00:00 2001 From: Mari Imaizumi Date: Mon, 13 May 2024 00:58:53 +0900 Subject: [PATCH 6/7] Replace @using_delete_command with @undoing --- lib/reline/line_editor.rb | 38 ++++---------------------------------- 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/lib/reline/line_editor.rb b/lib/reline/line_editor.rb index 1b054a91b4..7afe354e4d 100644 --- a/lib/reline/line_editor.rb +++ b/lib/reline/line_editor.rb @@ -34,31 +34,6 @@ class Reline::LineEditor vi_end_big_word } - DELETE_COMMANDS = %i{ - backward_delete_char - backward_kill_word - delete_char - ed_delete_next_char - ed_delete_prev_char - ed_delete_prev_word - ed_kill_line - em_delete - em_delete_next_word - em_delete_prev_char - em_kill_line - em_kill_region - kill_line - kill_whole_line - kill_word - unix_line_discard - unix_word_rubout - vi_change_to_eol - vi_delete_meta - vi_delete_meta_confirm - vi_delete_prev_char - vi_kill_line_prev - } - module CompletionState NORMAL = :normal COMPLETION = :completion @@ -276,7 +251,7 @@ def reset_variables(prompt = '', encoding:) @cache = {} @rendered_screen = RenderedScreen.new(base_y: 0, lines: [], cursor_y: 0) @past_lines = [] - @using_delete_command = false + @undoing = false reset_line end @@ -1000,9 +975,6 @@ def dialog_proc_scope_completion_journey_data end def wrap_method_call(method_symbol, method_obj, key, with_operator = false) - if DELETE_COMMANDS.include?(method_symbol) - @using_delete_command = true - end if @config.editing_mode_is?(:emacs, :vi_insert) and @vi_waiting_operator.nil? not_insertion = method_symbol != :ed_insert process_insert(force: not_insertion) @@ -1190,16 +1162,14 @@ def save_old_buffer MAX_PAST_LINES = 100 def save_past_lines if @old_buffer_of_lines != @buffer_of_lines - if !@past_lines.empty? && !@using_delete_command && @buffer_of_lines == @past_lines.last.first - # When deleting, @buffer_of_lines and @past_lines.last.first become the same. - # If it is the same as the previous state, consider it undone and do not add to past_lines. + if !@past_lines.empty? && @undoing @past_lines.pop else # Save the state before the changes. @past_lines.push([@old_buffer_of_lines, @old_byte_pointer, @old_line_index]) end end - @using_delete_command = false + @undoing = false if @past_lines.size > MAX_PAST_LINES @past_lines.shift @@ -1985,7 +1955,6 @@ def finish private def em_delete_or_list(key) if current_line.empty? or @byte_pointer < current_line.bytesize - @using_delete_command = true em_delete(key) elsif !@config.autocompletion # show completed list result = call_completion_proc @@ -2564,6 +2533,7 @@ def finish 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) end From 7b648bf2766f8c3939f3754e92413bb4fe8f9315 Mon Sep 17 00:00:00 2001 From: Mari Imaizumi Date: Tue, 14 May 2024 02:56:59 +0900 Subject: [PATCH 7/7] Refactor `@past_lines` --- lib/reline/line_editor.rb | 23 ++++++++++++----------- test/reline/test_key_actor_emacs.rb | 9 +++++++++ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/reline/line_editor.rb b/lib/reline/line_editor.rb index 7afe354e4d..23ece60220 100644 --- a/lib/reline/line_editor.rb +++ b/lib/reline/line_editor.rb @@ -1137,7 +1137,8 @@ def input_key(key) @completion_journey_state = nil end - save_past_lines + push_past_lines unless @undoing + @undoing = false if @in_pasting clear_dialogs @@ -1159,18 +1160,15 @@ def save_old_buffer @old_line_index = @line_index.dup end - MAX_PAST_LINES = 100 - def save_past_lines + def push_past_lines if @old_buffer_of_lines != @buffer_of_lines - if !@past_lines.empty? && @undoing - @past_lines.pop - else - # Save the state before the changes. - @past_lines.push([@old_buffer_of_lines, @old_byte_pointer, @old_line_index]) - end + @past_lines.push([@old_buffer_of_lines, @old_byte_pointer, @old_line_index]) end - @undoing = false + trim_past_lines + end + MAX_PAST_LINES = 100 + def trim_past_lines if @past_lines.size > MAX_PAST_LINES @past_lines.shift end @@ -1354,7 +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 - save_past_lines + push_past_lines end def insert_text(text) @@ -2534,7 +2532,10 @@ def finish 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 d79b0a60e8..013ca2f7b3 100644 --- a/test/reline/test_key_actor_emacs.rb +++ b/test/reline/test_key_actor_emacs.rb @@ -1496,4 +1496,13 @@ def test_undo_with_multiline 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