Skip to content

Commit

Permalink
Add ability to complete words
Browse files Browse the repository at this point in the history
Continued pressing of the tab key inserts the next completion suggestion
into a line. When all completion suggestions are exhausted, the original
word is restored back.
  • Loading branch information
piotrmurach committed Jul 17, 2021
1 parent aa4606e commit 10448d6
Show file tree
Hide file tree
Showing 4 changed files with 265 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -4,6 +4,7 @@

### Added
* Add to line the ability to find a word to complete before the cursor
* Add ability to complete words
* Add :history_size keyword to allow configuration of maximum history size
* Add the ability to replace a current line in the history buffer
* Add support for preserving edits to lines stored in history
Expand Down
28 changes: 22 additions & 6 deletions lib/tty/reader.rb
Expand Up @@ -4,7 +4,7 @@
require "tty-screen"
require "wisper"

require_relative "reader/completions"
require_relative "reader/completer"
require_relative "reader/history"
require_relative "reader/line"
require_relative "reader/key_event"
Expand All @@ -22,10 +22,11 @@ class Reader
include Wisper::Publisher

# Key codes
BACKSPACE = 8
TAB = 9
NEWLINE = 10
CARRIAGE_RETURN = 13
NEWLINE = 10
BACKSPACE = 8
DELETE = 127
DELETE = 127

# Keys that terminate input
EXIT_KEYS = %i[ctrl_d ctrl_z].freeze
Expand Down Expand Up @@ -56,6 +57,11 @@ def self.windows?
attr_reader :track_history
alias track_history? track_history

# The handler for finding word completion suggestions
#
# @api public
attr_accessor :completion_handler

attr_reader :console

attr_reader :cursor
Expand All @@ -78,13 +84,16 @@ def self.windows?
# allow duplicate entires, false by default
# @param [Proc] history_exclude
# exclude lines from history, by default all lines are stored
# @param [Proc] completion_handler
# the hanlder for finding word completion suggestions
#
# @api public
def initialize(input: $stdin, output: $stdout, interrupt: :error,
env: ENV, track_history: true, history_cycle: false,
history_exclude: History::DEFAULT_EXCLUDE,
history_size: History::DEFAULT_SIZE,
history_duplicates: false)
history_duplicates: false,
completion_handler: nil)
@input = input
@output = output
@interrupt = interrupt
Expand All @@ -94,6 +103,8 @@ def initialize(input: $stdin, output: $stdout, interrupt: :error,
@history_exclude = history_exclude
@history_duplicates = history_duplicates
@history_size = history_size
@completion_handler = completion_handler
@completer = Completer.new(handler: completion_handler)

@console = select_console(input)
@history = History.new(history_size) do |h|
Expand Down Expand Up @@ -255,6 +266,7 @@ def read_line(prompt = "", value: "", echo: true, raw: true,
line = Line.new(value, prompt: prompt)
screen_width = TTY::Screen.width
history_in_use = false
previous_key_name = ""
buffer = ""

output.print(line)
Expand All @@ -273,7 +285,9 @@ def read_line(prompt = "", value: "", echo: true, raw: true,
clear_display(line, screen_width)
end

if key_name == :backspace || code == BACKSPACE
if (key_name == :tab || code == TAB) && completion_handler
@completer.complete(line, initial: previous_key_name != :tab)
elsif key_name == :backspace || code == BACKSPACE
if !line.start?
line.left
line.delete
Expand Down Expand Up @@ -321,6 +335,8 @@ def read_line(prompt = "", value: "", echo: true, raw: true,
end
end

previous_key_name = key_name

# trigger before line is printed to allow for line changes
trigger_key_event(char, line: line.to_s)

Expand Down
100 changes: 100 additions & 0 deletions lib/tty/reader/completer.rb
@@ -0,0 +1,100 @@
# frozen_string_literal: true

require_relative "completions"

module TTY
class Reader
# Responsible for word completion
#
# @api private
class Completer
# The completion suggestions
attr_reader :completions

# The handler for finding word completion suggestions
attr_reader :handler

# The word to complete
attr_reader :word

# Create a Completer instance
#
# @api private
def initialize(handler: nil)
@handler = handler
@completions = Completions.new
@show_initial = false
@word = ""
end

# Find a suggestion to complete a word
#
# @param [Line] line
# the line to complete a word in
#
# @return [Boolean, String]
# the completed word or false when no suggestion is found
#
# @api public
def complete(line, initial: false)
initial ? complete_initial(line) : complete_next(line)
end

# Find suggestions and complete the initial word
#
# @param [Line] line
# the line to complete a word in
#
# @return [Boolean, String]
# the completed word or false when no suggestion is found
#
# @api public
def complete_initial(line)
@word = line.word_to_complete
suggestions = handler.(word)
completions.clear

return false if suggestions.empty?

completions.concat(suggestions)
completed_word = completions.get

line.remove(word.length)
line.insert(completed_word)

completed_word
end

# Complete a word with the next suggestion from completions
#
# @param [Line] line
# the line to complete a word in
#
# @return [Boolean, String]
# the completed word or false when no suggestion is found
#
# @api public
def complete_next(line)
return false if completions.empty?

previous_suggestion = completions.get
if completions.last? && !@show_initial
@show_initial = true
completed_word = word
else
if @show_initial
@show_initial = false
previous_suggestion = word
end
completions.next
completed_word = completions.get
end

line.remove(previous_suggestion.length)
line.insert(completed_word)

completed_word
end
end # Completer
end # Reader
end # TTY
142 changes: 142 additions & 0 deletions spec/unit/complete_word_spec.rb
@@ -0,0 +1,142 @@
# frozen_string_literal: true

RSpec.describe TTY::Reader, "complete word" do
let(:input) { StringIO.new }
let(:output) { StringIO.new }
let(:env) { {"TTY_TEST" => true} }
let(:left) { "\e[D" }
let(:options) {
{input: input, output: output, env: env,
completion_handler: ->(word) do
@completions.grep(/\A#{Regexp.escape(word)}/)
end}
}

subject(:reader) { described_class.new(**options) }

it "finds no completions for a word" do
@completions = %w[aa ab ac]
input << "x" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("x\n")
output.rewind
expect(output.string).to eq("\e[2K\e[1Gx\e[2K\e[1Gx\e[2K\e[1Gx\n")
end

it "completes an empty line with the first suggestion" do
@completions = %w[aa ab ac]
input << "" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("aa\n")
output.rewind
expect(output.string).to eq("\e[2K\e[1Gaa\e[2K\e[1Gaa\n")
end

it "completes space inside line with the first suggestion" do
@completions = %w[aa ab ac]
input << "x" << " " << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("x aa\n")
end

it "completes a word using the first suggestion" do
@completions = %w[aa ab ac]
input << "x" << " " << "a" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("x aa\n")
end

it "completes a word using the next suggestion" do
@completions = %w[aa ab ac]
input << "x" << " " << "a" << "\t" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("x ab\n")
end

it "completes a word using the last suggestion" do
@completions = %w[aa ab ac]
input << "x " << "a" << "\t" << "\t" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("x ac\n")
end

it "cycles through completions back to the initial word" do
@completions = %w[aa ab ac]
input << "x " << "a" << "\t" << "\t" << "\t" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("x a\n")
end

it "cycles through completions and completes using the first suggestion" do
@completions = %w[aa ab ac]
input << "x " << "a" << "\t" << "\t" << "\t" << "\t" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("x aa\n")
end

it "resets suggestions when a new character is entered" do
@completions = %w[aa ab ac]
input << "x " << "a" << "\t" << "\b" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("x aa\n")
end

it "completes edited text within a line" do
@completions = %w[aa ab ac]
input << "bb" << left << left << " " << left
input << "a" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("aa bb\n")
end

it "completes within a word" do
@completions = %w[aa ab ac]
input << "x " << "aa" << left << "\t" << "\t" << "\n"
input.rewind

answer = reader.read_line

expect(answer).to eq("x aba\n")
end

it "completes a multiline input " do
@completions = %w[aa ab ac]
input << "a" << "\t" << "\n"
input << "a" << "\t" << "\t" << "\C-d"
input.rewind

answer = reader.read_multiline

expect(answer).to eq(%W[aa\n ab])
end
end

0 comments on commit 10448d6

Please sign in to comment.