Skip to content

Commit

Permalink
Refactor completion (#647)
Browse files Browse the repository at this point in the history
* Refactor completion: split autocompletion and tabcompletion logic and state

* Move completion candidate listup logic from dialog proc to LineEditor
  • Loading branch information
tompng authored Apr 1, 2024
1 parent a622704 commit c3c09ac
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 92 deletions.
30 changes: 10 additions & 20 deletions lib/reline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -219,26 +219,16 @@ def get_screen_size

Reline::DEFAULT_DIALOG_PROC_AUTOCOMPLETE = ->() {
# autocomplete
return nil unless config.autocompletion
if just_cursor_moving and completion_journey_data.nil?
# Auto complete starts only when edited
return nil
end
pre, target, post = retrieve_completion_block(true)
if target.nil? or target.empty? or (completion_journey_data&.pointer == -1 and target.size <= 3)
return nil
end
if completion_journey_data and completion_journey_data.list
result = completion_journey_data.list.dup
result.shift
pointer = completion_journey_data.pointer - 1
else
result = call_completion_proc_with_checking_args(pre, target, post)
pointer = nil
end
if result and result.size == 1 and result[0] == target and pointer != 0
result = nil
end
return unless config.autocompletion

journey_data = completion_journey_data
return unless journey_data

target = journey_data.list[journey_data.pointer]
result = journey_data.list.drop(1)
pointer = journey_data.pointer - 1
return if target.empty? || (result == [target] && pointer < 0)

target_width = Reline::Unicode.calculate_width(target)
x = cursor_pos.x - target_width
if x < 0
Expand Down
149 changes: 77 additions & 72 deletions lib/reline/line_editor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,13 @@ module CompletionState
NORMAL = :normal
COMPLETION = :completion
MENU = :menu
JOURNEY = :journey
MENU_WITH_PERFECT_MATCH = :menu_with_perfect_match
PERFECT_MATCH = :perfect_match
end

RenderedScreen = Struct.new(:base_y, :lines, :cursor_y, keyword_init: true)

CompletionJourneyData = Struct.new(:preposing, :postposing, :list, :pointer)
CompletionJourneyState = Struct.new(:line_index, :pre, :target, :post, :list, :pointer)

class MenuInfo
attr_reader :list
Expand Down Expand Up @@ -221,7 +220,7 @@ def reset_variables(prompt = '', encoding:)
@waiting_proc = nil
@waiting_operator_proc = nil
@waiting_operator_vi_arg = nil
@completion_journey_data = nil
@completion_journey_state = nil
@completion_state = CompletionState::NORMAL
@perfect_matched = nil
@menu_info = nil
Expand Down Expand Up @@ -559,6 +558,8 @@ def rerender
end

class DialogProcScope
CompletionJourneyData = Struct.new(:preposing, :postposing, :list, :pointer)

def initialize(line_editor, config, proc_to_exec, context)
@line_editor = line_editor
@config = config
Expand Down Expand Up @@ -622,7 +623,7 @@ def preferred_dialog_height
end

def completion_journey_data
@line_editor.instance_variable_get(:@completion_journey_data)
@line_editor.dialog_proc_scope_completion_journey_data
end

def config
Expand Down Expand Up @@ -851,9 +852,9 @@ def editing_mode
[target, preposing, completed, postposing]
end

private def complete(list, just_show_list = false)
private def complete(list, just_show_list)
case @completion_state
when CompletionState::NORMAL, CompletionState::JOURNEY
when CompletionState::NORMAL
@completion_state = CompletionState::COMPLETION
when CompletionState::PERFECT_MATCH
@dig_perfect_match_proc&.(@perfect_matched)
Expand Down Expand Up @@ -893,46 +894,44 @@ def editing_mode
end
end

private def move_completed_list(list, direction)
case @completion_state
when CompletionState::NORMAL, CompletionState::COMPLETION,
CompletionState::MENU, CompletionState::MENU_WITH_PERFECT_MATCH
@completion_state = CompletionState::JOURNEY
result = retrieve_completion_block
return if result.nil?
preposing, target, postposing = result
@completion_journey_data = CompletionJourneyData.new(
preposing, postposing,
[target] + list.select{ |item| item.start_with?(target) }, 0)
if @completion_journey_data.list.size == 1
@completion_journey_data.pointer = 0
else
case direction
when :up
@completion_journey_data.pointer = @completion_journey_data.list.size - 1
when :down
@completion_journey_data.pointer = 1
end
end
@completion_state = CompletionState::JOURNEY
else
case direction
when :up
@completion_journey_data.pointer -= 1
if @completion_journey_data.pointer < 0
@completion_journey_data.pointer = @completion_journey_data.list.size - 1
end
when :down
@completion_journey_data.pointer += 1
if @completion_journey_data.pointer >= @completion_journey_data.list.size
@completion_journey_data.pointer = 0
end
end
def dialog_proc_scope_completion_journey_data
return nil unless @completion_journey_state
line_index = @completion_journey_state.line_index
pre_lines = @buffer_of_lines[0...line_index].map { |line| line + "\n" }
post_lines = @buffer_of_lines[(line_index + 1)..-1].map { |line| line + "\n" }
DialogProcScope::CompletionJourneyData.new(
pre_lines.join + @completion_journey_state.pre,
@completion_journey_state.post + post_lines.join,
@completion_journey_state.list,
@completion_journey_state.pointer
)
end

private def move_completed_list(direction)
@completion_journey_state ||= retrieve_completion_journey_state
return false unless @completion_journey_state

if (delta = { up: -1, down: +1 }[direction])
@completion_journey_state.pointer = (@completion_journey_state.pointer + delta) % @completion_journey_state.list.size
end
completed = @completion_journey_data.list[@completion_journey_data.pointer]
line_to_pointer = (@completion_journey_data.preposing + completed).split("\n")[@line_index] || String.new(encoding: @encoding)
new_line = line_to_pointer + (@completion_journey_data.postposing.split("\n").first || '')
set_current_line(new_line, line_to_pointer.bytesize)
completed = @completion_journey_state.list[@completion_journey_state.pointer]
set_current_line(@completion_journey_state.pre + completed + @completion_journey_state.post, @completion_journey_state.pre.bytesize + completed.bytesize)
true
end

private def retrieve_completion_journey_state
preposing, target, postposing = retrieve_completion_block
list = call_completion_proc
return unless list.is_a?(Array)

candidates = list.select{ |item| item.start_with?(target) }
return if candidates.empty?

pre = preposing.split("\n", -1).last || ''
post = postposing.split("\n", -1).first || ''
CompletionJourneyState.new(
@line_index, pre, target, post, [target] + candidates, 0
)
end

private def run_for_operators(key, method_symbol, &block)
Expand Down Expand Up @@ -1121,50 +1120,56 @@ def input_key(key)
@first_char = false
completion_occurs = false
if @config.editing_mode_is?(:emacs, :vi_insert) and key.char == "\C-i".ord
unless @config.disable_completion
result = call_completion_proc
if result.is_a?(Array)
completion_occurs = true
process_insert
if @config.autocompletion
move_completed_list(result, :down)
else
complete(result)
if !@config.disable_completion
process_insert(force: true)
if @config.autocompletion
@completion_state = CompletionState::NORMAL
completion_occurs = move_completed_list(:down)
else
@completion_journey_state = nil
result = call_completion_proc
if result.is_a?(Array)
completion_occurs = true
complete(result, false)
end
end
end
elsif @config.editing_mode_is?(:emacs, :vi_insert) and key.char == :completion_journey_up
if not @config.disable_completion and @config.autocompletion
result = call_completion_proc
if result.is_a?(Array)
completion_occurs = true
process_insert
move_completed_list(result, :up)
end
process_insert(force: true)
@completion_state = CompletionState::NORMAL
completion_occurs = move_completed_list(:up)
end
elsif not @config.disable_completion and @config.editing_mode_is?(:vi_insert) and ["\C-p".ord, "\C-n".ord].include?(key.char)
unless @config.disable_completion
result = call_completion_proc
if result.is_a?(Array)
completion_occurs = true
process_insert
move_completed_list(result, "\C-p".ord == key.char ? :up : :down)
end
elsif @config.editing_mode_is?(:vi_insert) and ["\C-p".ord, "\C-n".ord].include?(key.char)
# In vi mode, move completed list even if autocompletion is off
if not @config.disable_completion
process_insert(force: true)
@completion_state = CompletionState::NORMAL
completion_occurs = move_completed_list("\C-p".ord == key.char ? :up : :down)
end
elsif Symbol === key.char and respond_to?(key.char, true)
process_key(key.char, key.char)
else
normal_char(key)
end

unless completion_occurs
@completion_state = CompletionState::NORMAL
@completion_journey_data = nil
@completion_journey_state = nil
end

if @in_pasting
clear_dialogs
else
return old_lines != @buffer_of_lines
return
end

modified = old_lines != @buffer_of_lines
if !completion_occurs && modified && !@config.disable_completion && @config.autocompletion
# Auto complete starts only when edited
process_insert(force: true)
@completion_journey_state = retrieve_completion_journey_state
end
modified
end

def scroll_into_view
Expand Down Expand Up @@ -2042,7 +2047,7 @@ def finish
private def em_delete_or_list(key)
if current_line.empty? or @byte_pointer < current_line.bytesize
em_delete(key)
else # show completed list
elsif !@config.autocompletion # show completed list
result = call_completion_proc
if result.is_a?(Array)
complete(result, true)
Expand Down
13 changes: 13 additions & 0 deletions test/reline/yamatanooroti/test_rendering.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,19 @@ def test_autocomplete_target_is_wrapped
EOC
end

def test_force_insert_before_autocomplete
start_terminal(20, 20, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl --autocomplete}, startup_message: 'Multiline REPL.')
write('Sy')
write(";St\t\t")
close
assert_screen(<<~'EOC')
Multiline REPL.
prompt> Sy;Struct
String
Struct
EOC
end

def test_simple_dialog_with_scroll_key
start_terminal(20, 50, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl --dialog long,scrollkey}, startup_message: 'Multiline REPL.')
write('a')
Expand Down

0 comments on commit c3c09ac

Please sign in to comment.