tty-reader-0.7.0/0000755000175000017500000000000013621165543013444 5ustar gabstergabstertty-reader-0.7.0/tty-reader.gemspec0000644000175000017500000000372313621165543017076 0ustar gabstergabsterlib = File.expand_path("../lib", __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "tty/reader/version" Gem::Specification.new do |spec| spec.name = "tty-reader" spec.version = TTY::Reader::VERSION spec.authors = ["Piotr Murach"] spec.email = ["me@piotrmurach.com"] spec.summary = %q{A set of methods for processing keyboard input in character, line and multiline modes.} spec.description = %q{A set of methods for processing keyboard input in character, line and multiline modes. It maintains history of entered input with an ability to recall and re-edit those inputs. It lets you register to listen for keystroke events and trigger custom key events yourself.} spec.homepage = "https://piotrmurach.github.io/tty" spec.license = "MIT" if spec.respond_to?(:metadata=) spec.metadata = { "allowed_push_host" => "https://rubygems.org", "bug_tracker_uri" => "https://github.com/piotrmurach/tty-reader/issues", "changelog_uri" => "https://github.com/piotrmurach/tty-reader/blob/master/CHANGELOG.md", "documentation_uri" => "https://www.rubydoc.info/gems/tty-reader", "homepage_uri" => spec.homepage, "source_code_uri" => "https://github.com/piotrmurach/tty-reader" } end spec.files = Dir['{lib,spec,examples,benchmarks}/**/*.rb'] spec.files += Dir['{bin,tasks}/*', 'tty-reader.gemspec'] spec.files += Dir['README.md', 'CHANGELOG.md', 'LICENSE.txt', 'Rakefile'] spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.required_ruby_version = '>= 2.0.0' spec.add_dependency "wisper", "~> 2.0.0" spec.add_dependency "tty-screen", "~> 0.7" spec.add_dependency "tty-cursor", "~> 0.7" spec.add_development_dependency "bundler", ">= 1.5.0" spec.add_development_dependency "rake" spec.add_development_dependency "rspec", "~> 3.0" end tty-reader-0.7.0/tasks/0000755000175000017500000000000013621165543014571 5ustar gabstergabstertty-reader-0.7.0/tasks/spec.rake0000644000175000017500000000125513621165543016372 0ustar gabstergabster# encoding: utf-8 begin require 'rspec/core/rake_task' desc 'Run all specs' RSpec::Core::RakeTask.new(:spec) do |task| task.pattern = 'spec/{unit,integration}{,/*/**}/*_spec.rb' end namespace :spec do desc 'Run unit specs' RSpec::Core::RakeTask.new(:unit) do |task| task.pattern = 'spec/unit{,/*/**}/*_spec.rb' end desc 'Run integration specs' RSpec::Core::RakeTask.new(:integration) do |task| task.pattern = 'spec/integration{,/*/**}/*_spec.rb' end end rescue LoadError %w[spec spec:unit spec:integration].each do |name| task name do $stderr.puts "In order to run #{name}, do `gem install rspec`" end end end tty-reader-0.7.0/tasks/coverage.rake0000644000175000017500000000032213621165543017225 0ustar gabstergabster# encoding: utf-8 desc 'Measure code coverage' task :coverage do begin original, ENV['COVERAGE'] = ENV['COVERAGE'], 'true' Rake::Task['spec'].invoke ensure ENV['COVERAGE'] = original end end tty-reader-0.7.0/tasks/console.rake0000644000175000017500000000033313621165543017076 0ustar gabstergabster# encoding: utf-8 desc 'Load gem inside irb console' task :console do require 'irb' require 'irb/completion' require File.join(__FILE__, '../../lib/tty-reader') ARGV.clear IRB.start end task c: %w[ console ] tty-reader-0.7.0/spec/0000755000175000017500000000000013621165543014376 5ustar gabstergabstertty-reader-0.7.0/spec/unit/0000755000175000017500000000000013621165543015355 5ustar gabstergabstertty-reader-0.7.0/spec/unit/subscribe_spec.rb0000644000175000017500000000303413621165543020675 0ustar gabstergabster# frozen_string_literal: true RSpec.describe TTY::Reader, '#subscribe' do let(:input) { StringIO.new } let(:output) { StringIO.new } let(:env) { { "TTY_TEST" => true } } it "subscribes to receive events" do stub_const("Context", Class.new do def initialize(events) @events = events end def keypress(event) @events << [:keypress, event.value] end end) reader = TTY::Reader.new(input: input, output: output, env: env) events = [] context = Context.new(events) reader.subscribe(context) input << "aa\n" input.rewind answer = reader.read_line expect(answer).to eq("aa\n") expect(events).to eq([ [:keypress, "a"], [:keypress, "a"], [:keypress, "\n"] ]) events.clear reader.unsubscribe(context) input.rewind answer = reader.read_line expect(events).to eq([]) end it "subscribes to listen to events only in a block" do stub_const("Context", Class.new do def initialize(events) @events = events end def keypress(event) @events << [:keypress, event.value] end end) reader = TTY::Reader.new(input: input, output: output, env: env) events = [] context = Context.new(events) input << "aa\nbb\n" input.rewind reader.subscribe(context) do reader.read_line end answer = reader.read_line expect(answer).to eq("bb\n") expect(events).to eq([ [:keypress, "a"], [:keypress, "a"], [:keypress, "\n"] ]) end end tty-reader-0.7.0/spec/unit/read_multiline_spec.rb0000644000175000017500000000401313621165543021707 0ustar gabstergabster# frozen_string_literal: true RSpec.describe TTY::Reader, '#read_multiline' do let(:input) { StringIO.new } let(:output) { StringIO.new } let(:env) { { "TTY_TEST" => true } } subject(:reader) { described_class.new(input: input, output: output, env: env) } it 'reads no lines' do input << "\C-d" input.rewind answer = reader.read_multiline expect(answer).to eq([]) end it "reads a line and terminates on Ctrl+d" do input << "Single line\C-d" input.rewind answer = reader.read_multiline expect(answer).to eq(["Single line"]) end it "reads a line and terminates on Ctrl+z" do input << "Single line\C-z" input.rewind answer = reader.read_multiline expect(answer).to eq(["Single line"]) end it 'reads few lines' do input << "First line\nSecond line\nThird line\n\C-d" input.rewind answer = reader.read_multiline expect(answer).to eq(["First line\n", "Second line\n", "Third line\n"]) end it "skips empty lines" do input << "\n\nFirst line\n\n\n\n\nSecond line\C-d" input.rewind answer = reader.read_multiline expect(answer).to eq(["First line\n", "Second line"]) end it 'reads and yiels every line' do input << "First line\nSecond line\nThird line\C-z" input.rewind lines = [] reader.read_multiline { |line| lines << line } expect(lines).to eq(["First line\n", "Second line\n", "Third line"]) end it 'reads multibyte lines' do input << "국경의 긴 터널을 빠져나오자\n설국이었다.\C-d" input.rewind lines = [] reader.read_multiline { |line| lines << line } expect(lines).to eq(["국경의 긴 터널을 빠져나오자\n", '설국이었다.']) end it 'reads lines with a prompt' do input << "1\n2\n3\C-d" input.rewind reader.read_multiline(">> ") expect(output.string).to eq([ ">> ", "\e[2K\e[1G>> 1", "\e[2K\e[1G>> 1\n", ">> ", "\e[2K\e[1G>> 2", "\e[2K\e[1G>> 2\n", ">> ", "\e[2K\e[1G>> 3", ].join) end end tty-reader-0.7.0/spec/unit/read_line_spec.rb0000644000175000017500000000470013621165543020637 0ustar gabstergabster# frozen_string_literal: true RSpec.describe TTY::Reader, '#read_line' do let(:input) { StringIO.new } let(:output) { StringIO.new } let(:env) { { "TTY_TEST" => true } } subject(:reader) { described_class.new(input: input, output: output, env: env) } it 'masks characters' do input << "password\n" input.rewind answer = reader.read_line(echo: false) expect(answer).to eq("password\n") end it "echoes characters back" do input << "password\n" input.rewind answer = reader.read_line expect(answer).to eq("password\n") expect(output.string).to eq([ "\e[2K\e[1Gp", "\e[2K\e[1Gpa", "\e[2K\e[1Gpas", "\e[2K\e[1Gpass", "\e[2K\e[1Gpassw", "\e[2K\e[1Gpasswo", "\e[2K\e[1Gpasswor", "\e[2K\e[1Gpassword", "\e[2K\e[1Gpassword\n" ].join) end it "doesn't echo characters back" do input << "password\n" input.rewind answer = reader.read_line(echo: false) expect(answer).to eq("password\n") expect(output.string).to eq("\n") end it "displays a prompt before input" do input << "aa\n" input.rewind answer = reader.read_line('>> ') expect(answer).to eq("aa\n") expect(output.string).to eq([ ">> ", "\e[2K\e[1G>> a", "\e[2K\e[1G>> aa", "\e[2K\e[1G>> aa\n" ].join) end it "displays custom input with a prompt" do input << "aa\n" input.rewind answer = reader.read_line("> ", value: "xx") expect(answer).to eq("xxaa\n") expect(output.string).to eq([ "> xx", "\e[2K\e[1G> xxa", "\e[2K\e[1G> xxaa", "\e[2K\e[1G> xxaa\n" ].join) end it 'deletes characters when backspace pressed' do input << "aa\ba\bcc\n" input.rewind answer = reader.read_line expect(answer).to eq("acc\n") end it 'reads multibyte line' do input << "한글" input.rewind answer = reader.read_line expect(answer).to eq("한글") end it "supports multiline prompts" do allow(TTY::Screen).to receive(:width).and_return(50) prompt = "one\ntwo\nthree" input << "aa\n" input.rewind answer = reader.read_line(prompt) expect(answer).to eq("aa\n") expect(output.string).to eq([ prompt, "\e[2K\e[1G\e[1A" * 2, "\e[2K\e[1G", prompt + "a", "\e[2K\e[1G\e[1A" * 2, "\e[2K\e[1G", prompt + "aa", "\e[2K\e[1G\e[1A" * 2, "\e[2K\e[1G", prompt + "aa\n" ].join) end end tty-reader-0.7.0/spec/unit/read_keypress_spec.rb0000644000175000017500000000440513621165543021557 0ustar gabstergabster# frozen_string_literal: true RSpec.describe TTY::Reader, '#read_keypress' do let(:input) { StringIO.new } let(:out) { StringIO.new } let(:env) { { "TTY_TEST" => true } } it "reads single key press" do reader = described_class.new(input: input, output: out, env: env) input << "\e[Aaaaaaa\n" input.rewind answer = reader.read_keypress expect(answer).to eq("\e[A") end it 'reads multibyte key press' do reader = described_class.new(input: input, output: out, env: env) input << "ㄱ" input.rewind answer = reader.read_keypress expect(answer).to eq("ㄱ") end context 'when Ctrl+C pressed' do it "defaults to raising InputInterrupt" do reader = described_class.new(input: input, output: out, env: env) input << "\x03" input.rewind expect { reader.read_keypress }.to raise_error(TTY::Reader::InputInterrupt) end it "sends interrupt signal when :signal option is chosen" do reader = described_class.new( input: input, output: out, interrupt: :signal, env: env) input << "\x03" input.rewind allow(Process).to receive(:pid).and_return(666) allow(Process).to receive(:kill) expect(Process).to receive(:kill).with('SIGINT', 666) reader.read_keypress end it "exits with 130 code when :exit option is chosen" do reader = described_class.new( input: input, output: out, interrupt: :exit, env: env) input << "\x03" input.rewind expect { reader.read_keypress }.to raise_error(SystemExit) end it "evaluates custom handler when proc object is provided" do handler = proc { raise ArgumentError } reader = described_class.new( input: input, output: out, interrupt: handler, env: env) input << "\x03" input.rewind expect { reader.read_keypress }.to raise_error(ArgumentError) end it "skips handler when handler is nil" do reader = described_class.new( input: input, output: out, interrupt: :noop, env: env) input << "\x03" input.rewind expect(reader.read_keypress).to eq("\x03") end end end tty-reader-0.7.0/spec/unit/publish_keypress_event_spec.rb0000644000175000017500000000626113621165543023515 0ustar gabstergabster# frozen_string_literal: true RSpec.describe TTY::Reader, '#publish_keypress_event' do let(:input) { StringIO.new } let(:out) { StringIO.new } let(:env) { { "TTY_TEST" => true } } let(:reader) { described_class.new(input: input, output: out, env: env) } it "publishes :keypress events" do input << "abc\n" input.rewind chars = [] lines = [] reader.on(:keypress) { |event| chars << event.value; lines << event.line } answer = reader.read_line expect(chars).to eq(%W(a b c \n)) expect(lines).to eq(%W(a ab abc abc\n)) expect(answer).to eq("abc\n") end it "publishes :keyescape events" do input << "a\e" input.rewind keys = [] reader.on(:keypress) { |event| keys << "keypress_#{event.value}"} reader.on(:keyescape) { |event| keys << "keyescape_#{event.value}" } answer = reader.read_line expect(keys).to eq(["keypress_a", "keyescape_\e", "keypress_\e"]) expect(answer).to eq("a\e") end it "publishes :keyup for read_keypress" do input << "\e[Aaa" input.rewind keys = [] reader.on(:keypress) { |event| keys << "keypress_#{event.value}" } reader.on(:keyup) { |event| keys << "keyup_#{event.value}" } reader.on(:keydown) { |event| keys << "keydown_#{event.value}" } answer = reader.read_keypress expect(keys).to eq(["keyup_\e[A", "keypress_\e[A"]) expect(answer).to eq("\e[A") end it "publishes :keydown event for read_keypress" do input << "\e[Baa" input.rewind keys = [] reader.on(:keypress) { |event| keys << "keypress_#{event.value}" } reader.on(:keyup) { |event| keys << "keyup_#{event.value}" } reader.on(:keydown) { |event| keys << "keydown_#{event.value}" } answer = reader.read_keypress expect(keys).to eq(["keydown_\e[B", "keypress_\e[B"]) expect(answer).to eq("\e[B") end it "publishes :keynum event" do input << "5aa" input.rewind keys = [] reader.on(:keypress) { |event| keys << "keypress_#{event.value}" } reader.on(:keyup) { |event| keys << "keyup_#{event.value}" } reader.on(:keynum) { |event| keys << "keynum_#{event.value}" } answer = reader.read_keypress expect(keys).to eq(["keynum_5", "keypress_5"]) expect(answer).to eq("5") end it "publishes :keyreturn event" do input << "\r" input.rewind keys = [] reader.on(:keypress) { |event| keys << "keypress" } reader.on(:keyup) { |event| keys << "keyup" } reader.on(:keyreturn) { |event| keys << "keyreturn" } answer = reader.read_keypress expect(keys).to eq(["keyreturn", "keypress"]) expect(answer).to eq("\r") end it "subscribes to multiple events" do input << "\n" input.rewind keys = [] reader.on(:keyenter) { |event| keys << "keyenter" } .on(:keypress) { |event| keys << "keypress" } answer = reader.read_keypress expect(keys).to eq(["keyenter", "keypress"]) expect(answer).to eq("\n") end it "subscribes to ctrl+X type of event event" do input << ?\C-z input.rewind keys = [] reader.on(:keyctrl_z) { |event| keys << "ctrl_z" } answer = reader.read_keypress expect(keys).to eq(['ctrl_z']) expect(answer).to eq(?\C-z) end end tty-reader-0.7.0/spec/unit/line_spec.rb0000644000175000017500000000713013621165543017644 0ustar gabstergabster# frozen_string_literal: true RSpec.describe TTY::Reader::Line do it "provides access to the prompt" do line = described_class.new('aaa', prompt: '>> ') expect(line.prompt).to eq('>> ') expect(line.text).to eq('aaa') expect(line.size).to eq(6) expect(line.to_s).to eq(">> aaa") end it "inserts characters inside a line" do line = described_class.new('aaaaa') line[0] = 'test' expect(line.text).to eq('testaaaaa') line[4..6] = '' expect(line.text).to eq('testaa') end it "moves cursor left and right" do line = described_class.new('aaaaa') 5.times { line.left } expect(line.cursor).to eq(0) expect(line.start?).to eq(true) line.left(5) expect(line.cursor).to eq(0) line.right(20) expect(line.cursor).to eq(5) expect(line.end?).to eq(true) end it "inserts char at start of the line" do line = described_class.new('aaaaa') expect(line.cursor).to eq(5) line[0] = 'b' expect(line.cursor).to eq(1) expect(line.text).to eq('baaaaa') line.insert('b') expect(line.text).to eq('bbaaaaa') end it "inserts char at end of the line" do line = described_class.new('aaaaa') expect(line.cursor).to eq(5) line[4] = 'b' expect(line.cursor).to eq(5) expect(line.text).to eq('aaaaba') end it "inserts char inside the line" do line = described_class.new('aaaaa') expect(line.cursor).to eq(5) line[2] = 'b' expect(line.cursor).to eq(3) expect(line.text).to eq('aabaaa') end it "inserts char outside of the line size" do line = described_class.new('aaaaa') expect(line.cursor).to eq(5) line[10] = 'b' expect(line.cursor).to eq(11) expect(line.text).to eq('aaaaa b') end it "inserts chars in empty string" do line = described_class.new('') expect(line.cursor).to eq(0) line.insert('a') expect(line.cursor).to eq(1) line.insert('b') expect(line.cursor).to eq(2) expect(line.to_s).to eq('ab') line.insert('cc') expect(line.cursor).to eq(4) expect(line.to_s).to eq('abcc') end it "inserts characters with #insert call" do line = described_class.new('aaaaa') expect(line.cursor).to eq(5) line.left(2) expect(line.cursor).to eq(3) line.insert(' test ') expect(line.text).to eq('aaa test aa') expect(line.cursor).to eq(9) line.right expect(line.cursor).to eq(10) end it "removes char before current cursor position" do line = described_class.new('abcdef') expect(line.cursor).to eq(6) line.remove(2) expect(line.text).to eq('abcd') expect(line.cursor).to eq(4) line.left line.left line.remove expect(line.text).to eq('acd') expect(line.cursor).to eq(1) line.insert('x') expect(line.text).to eq('axcd') end it "deletes char under current cursor position" do line = described_class.new('abcdef') line.left(3) line.delete expect(line.text).to eq('abcef') line.right line.delete expect(line.text).to eq('abce') line.left(4) line.delete expect(line.text).to eq('bce') end it "replaces current line with new preserving cursor" do line = described_class.new('x' * 6) expect(line.text).to eq('xxxxxx') expect(line.cursor).to eq(6) expect(line.mode).to eq(:edit) expect(line.editing?).to eq(true) line.replace('y' * 8) expect(line.text).to eq('y' * 8) expect(line.cursor).to eq(8) expect(line.replacing?).to eq(true) line.insert('z') expect(line.text).to eq('y' * 8 + 'z') expect(line.cursor).to eq(9) expect(line.editing?).to eq(true) end end tty-reader-0.7.0/spec/unit/key_event_spec.rb0000644000175000017500000000544513621165543020715 0ustar gabstergabster# frozen_string_literal: true require 'shellwords' RSpec.describe TTY::Reader::KeyEvent, '#from' do let(:keys) { TTY::Reader::Keys.keys } it "parses backspace" do event = described_class.from(keys, "\x7f") expect(event.key.name).to eq(:backspace) expect(event.value).to eq("\x7f") end it "parses lowercase char" do event = described_class.from(keys, 'a') expect(event.key.name).to eq(:alpha) expect(event.value).to eq('a') end it "parses uppercase char" do event = described_class.from(keys, 'A') expect(event.key.name).to eq(:alpha) expect(event.value).to eq('A') end it "parses number char" do event = described_class.from(keys, '666') expect(event.key.name).to eq(:num) expect(event.value).to eq('666') end it "parses ctrl-a to ctrl-z inputs" do (1..26).zip('a'..'z').each do |code, char| event = described_class.from(TTY::Reader::Keys.ctrl_keys, code.chr) expect(event.key.name).to eq(:"ctrl_#{char}") expect(event.value).to eq(code.chr) end end it "parses uknown key" do no_keys = {} event = described_class.from(no_keys, '*') expect(event.key.name).to eq(:ignore) expect(event.value).to eq('*') end it "exposes line value" do event = described_class.from(keys, 'c', 'ab') expect(event.line).to eq('ab') end # F1-F12 keys { f1: ["\eOP","\e[[A","\e[11~"], f2: ["\eOQ","\e[[B","\e[12~"], f3: ["\eOR","\e[[C","\e[13~"], f4: ["\eOS","\e[[D","\e[14~"], f5: [ "\e[[E","\e[15~"], f6: [ "\e[17~"], f7: [ "\e[18~"], f8: [ "\e[19~"], f9: [ "\e[20~"], f10: [ "\e[21~"], f11: [ "\e[23~"], f12: [ "\e[24~"] }.each do |name, codes| codes.each do |code| it "parses #{Shellwords.escape(code)} as #{name} key" do event = described_class.from(keys, code) expect(event.key.name).to eq(name) expect(event.key.meta).to eq(false) expect(event.key.ctrl).to eq(false) expect(event.key.shift).to eq(false) end end end # arrow keys & text editing { up: ["\e[A"], down: ["\e[B"], right: ["\e[C"], left: ["\e[D"], clear: ["\e[E"], home: ["\e[1~", "\e[7~", "\e[H"], end: ["\e[4~", "\eOF", "\e[F"], insert: ["\e[2~"], delete: ["\e[3~"], page_up: ["\e[5~"], page_down: ["\e[6~"] }.each do |name, codes| codes.each do |code| it "parses #{Shellwords.escape(code)} as #{name} key" do event = described_class.from(keys, code) expect(event.key.name).to eq(name) expect(event.key.meta).to eq(false) expect(event.key.ctrl).to eq(false) expect(event.key.shift).to eq(false) end end end end tty-reader-0.7.0/spec/unit/history_spec.rb0000644000175000017500000001054313621165543020420 0ustar gabstergabster# frozen_string_literal: true RSpec.describe TTY::Reader::History do it "has no lines" do history = described_class.new expect(history.size).to eq(0) end it "doesn't navigate through empty buffer" do history = described_class.new expect(history.next?).to eq(false) expect(history.previous?).to eq(false) end it "allows to cycle through non-empty buffer" do history = described_class.new(3, cycle: true) history << "line" expect(history.next?).to eq(true) expect(history.previous?).to eq(true) end it "defaults maximum size" do history = described_class.new expect(history.max_size).to eq(512) end it "presents string representation" do history = described_class.new expect(history.to_s).to eq("[]") end it "adds items to history without overflowing" do history = described_class.new(3) history << "line #1" history << "line #2" history << "line #3" history << "line #4" expect(history.to_a).to eq(["line #2", "line #3", "line #4"]) expect(history.index).to eq(2) end it "excludes items" do exclude = proc { |line| /line #[23]/.match(line) } history = described_class.new(exclude: exclude) history << "line #1" history << "line #2" history << "line #3" expect(history.to_a).to eq(["line #1"]) expect(history.index).to eq(0) end it "allows duplicates" do history = described_class.new history << "line #1" history << "line #1" history << "line #1" expect(history.to_a).to eq(["line #1", "line #1", "line #1"]) end it "prevents duplicates" do history = described_class.new(duplicates: false) history << "line #1" history << "line #1" history << "line #1" expect(history.to_a).to eq(["line #1"]) end it "navigates through history buffer without cycling" do history = described_class.new(3) history << "line #1" history << "line #2" history << "line #3" expect(history.index).to eq(2) history.previous history.previous expect(history.index).to eq(0) history.previous expect(history.index).to eq(0) history.next history.next expect(history.index).to eq(2) history.next expect(history.next?).to eq(false) expect(history.index).to eq(2) end it "navigates through history buffer with cycling" do history = described_class.new(3, cycle: true) history << "line #1" history << "line #2" history << "line #3" expect(history.index).to eq(2) history.previous history.previous expect(history.index).to eq(0) history.previous expect(history.index).to eq(2) expect(history.next?).to eq(true) history.next history.next expect(history.index).to eq(1) history.next expect(history.index).to eq(2) end it "checks if navigation is possible" do history = described_class.new(3) expect(history.index).to eq(nil) expect(history.previous?).to eq(false) expect(history.next?).to eq(false) history << "line #1" history << "line #2" expect(history.index).to eq(1) expect(history.previous?).to eq(true) expect(history.next?).to eq(false) history.previous expect(history.index).to eq(0) expect(history.previous?).to eq(true) expect(history.next?).to eq(true) history.previous expect(history.index).to eq(0) expect(history.previous?).to eq(true) expect(history.next?).to eq(true) end it "gets line based on index" do history = described_class.new(3, cycle: true) history << "line #1" history << "line #2" history << "line #3" expect(history[-1]).to eq('line #3') expect(history[1]).to eq('line #2') expect { history[11] }.to raise_error(IndexError, 'invalid index') end it "retrieves current line" do history = described_class.new(3, cycle: true) expect(history.get).to eq(nil) history << "line #1" history << "line #2" history << "line #3" expect(history.get).to eq("line #3") history.previous history.previous expect(history.get).to eq("line #1") history.next expect(history.get).to eq("line #2") end it "clears all lines" do history = described_class.new(3) history << "line #1" history << "line #2" history << "line #3" expect(history.size).to eq(3) history.clear expect(history.size).to eq(0) expect(history.index).to eq(0) end end tty-reader-0.7.0/spec/spec_helper.rb0000644000175000017500000000210313621165543017210 0ustar gabstergabster# frozen_string_literal: true if ENV['COVERAGE'] || ENV['TRAVIS'] require 'simplecov' require 'coveralls' SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ SimpleCov::Formatter::HTMLFormatter, Coveralls::SimpleCov::Formatter ]) SimpleCov.start do command_name 'spec' add_filter 'spec' end end require "bundler/setup" require "tty-reader" class StringIO def wait_readable(*) true end end RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" config.mock_with :rspec do |mocks| mocks.verify_partial_doubles = true end # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! # This setting enables warnings. It's recommended, but in some cases may # be too noisy due to issues in dependencies. config.warnings = true if config.files_to_run.one? config.default_formatter = 'doc' end config.profile_examples = 2 config.order = :random Kernel.srand config.seed end tty-reader-0.7.0/lib/0000755000175000017500000000000013621165543014212 5ustar gabstergabstertty-reader-0.7.0/lib/tty/0000755000175000017500000000000013621165543015032 5ustar gabstergabstertty-reader-0.7.0/lib/tty/reader/0000755000175000017500000000000013621165543016274 5ustar gabstergabstertty-reader-0.7.0/lib/tty/reader/win_console.rb0000644000175000017500000000403713621165543021144 0ustar gabstergabster# frozen_string_literal: true require_relative 'keys' module TTY class Reader class WinConsole ESC = "\e".freeze NUL_HEX = "\x00".freeze EXT_HEX = "\xE0".freeze # Key codes # # @return [Hash[Symbol]] # # @api public attr_reader :keys # Escape codes # # @return [Array[Integer]] # # @api public attr_reader :escape_codes def initialize(input) require_relative 'win_api' @input = input @keys = Keys.ctrl_keys.merge(Keys.win_keys) @escape_codes = [[NUL_HEX.ord], [ESC.ord], EXT_HEX.bytes.to_a] end # Get a character from console blocking for input # # @param [Hash[Symbol]] options # @option options [Symbol] :echo # the echo mode toggle # @option options [Symbol] :raw # the raw mode toggle # # @return [String] # # @api private def get_char(options) if options[:raw] && options[:echo] if options[:nonblock] get_char_echo_non_blocking else get_char_echo_blocking end elsif options[:raw] && !options[:echo] options[:nonblock] ? get_char_non_blocking : get_char_blocking elsif !options[:raw] && !options[:echo] options[:nonblock] ? get_char_non_blocking : get_char_blocking else @input.getc end end # Get the char for last key pressed, or if no keypress return nil # # @api private def get_char_non_blocking input_ready? ? get_char_blocking : nil end def get_char_echo_non_blocking input_ready? ? get_char_echo_blocking : nil end def get_char_blocking WinAPI.getch.chr end def get_char_echo_blocking WinAPI.getche.chr end # Check if IO has user input # # @return [Boolean] # # @api private def input_ready? !WinAPI.kbhit.zero? end end # Console end # Reader end # TTY tty-reader-0.7.0/lib/tty/reader/win_api.rb0000644000175000017500000000242713621165543020254 0ustar gabstergabster# frozen_string_literal: true require 'fiddle' module TTY class Reader module WinAPI include Fiddle CRT_HANDLE = Fiddle::Handle.new("msvcrt") rescue Fiddle::Handle.new("crtdll") # Get a character from the console without echo. # # @return [String] # return the character read # # @api public def getch @@getch ||= Fiddle::Function.new(CRT_HANDLE["_getch"], [], TYPE_INT) @@getch.call end module_function :getch # Gets a character from the console with echo. # # @return [String] # return the character read # # @api public def getche @@getche ||= Fiddle::Function.new(CRT_HANDLE["_getche"], [], TYPE_INT) @@getche.call end module_function :getche # Check the console for recent keystroke. If the function # returns a nonzero value, a keystroke is waiting in the buffer. # # @return [Integer] # return a nonzero value if a key has been pressed. Otherwirse, # it returns 0. # # @api public def kbhit @@kbhit ||= Fiddle::Function.new(CRT_HANDLE["_kbhit"], [], TYPE_INT) @@kbhit.call end module_function :kbhit end # WinAPI end # Reader end # TTY tty-reader-0.7.0/lib/tty/reader/version.rb0000644000175000017500000000015013621165543020302 0ustar gabstergabster# frozen_string_literal: true module TTY class Reader VERSION = '0.7.0' end # Reader end # TTY tty-reader-0.7.0/lib/tty/reader/mode.rb0000644000175000017500000000137413621165543017552 0ustar gabstergabster# frozen_string_literal: true require 'io/console' module TTY class Reader class Mode # Initialize a Terminal # # @api public def initialize(input = $stdin) @input = input end # Echo given block # # @param [Boolean] is_on # # @api public def echo(is_on = true, &block) if is_on || !@input.tty? yield else @input.noecho(&block) end end # Use raw mode in the given block # # @param [Boolean] is_on # # @api public def raw(is_on = true, &block) if is_on && @input.tty? @input.raw(&block) else yield end end end # Mode end # Reader end # TTY tty-reader-0.7.0/lib/tty/reader/line.rb0000644000175000017500000001244313621165543017554 0ustar gabstergabster# frozen_string_literal: true require 'forwardable' module TTY class Reader class Line ANSI_MATCHER = /(\[)?\033(\[)?[;?\d]*[\dA-Za-z](\])?/ # Strip ANSI characters from the text # # @param [String] text # # @return [String] # # @api public def self.sanitize(text) text.dup.gsub(ANSI_MATCHER, '') end # The editable text # @api public attr_reader :text # The current cursor position witin the text # @api public attr_reader :cursor # The line mode # @api public attr_reader :mode # The prompt displayed before input # @api public attr_reader :prompt # Create a Line instance # # @api private def initialize(text = '', prompt: '') @prompt = prompt.dup @text = text.dup @cursor = [0, @text.length].max @mode = :edit yield self if block_given? end # Check if line is in edit mode # # @return [Boolean] # # @public def editing? @mode == :edit end # Enable edit mode # # @return [Boolean] # # @public def edit_mode @mode = :edit end # Check if line is in replace mode # # @return [Boolean] # # @public def replacing? @mode == :replace end # Enable replace mode # # @return [Boolean] # # @public def replace_mode @mode = :replace end # Check if cursor reached beginning of the line # # @return [Boolean] # # @api public def start? @cursor.zero? end # Check if cursor reached end of the line # # @return [Boolean] # # @api public def end? @cursor == @text.length end # Move line position to the left by n chars # # @api public def left(n = 1) @cursor = [0, @cursor - n].max end # Move line position to the right by n chars # # @api public def right(n = 1) @cursor = [@text.length, @cursor + n].min end # Move cursor to beginning position # # @api public def move_to_start @cursor = 0 end # Move cursor to end position # # @api public def move_to_end @cursor = @text.length # put cursor outside of text end # Insert characters inside a line. When the lines exceeds # maximum length, an extra space is added to accomodate index. # # @param [Integer] i # the index to insert at # # @param [String] chars # the characters to insert # # @example # text = 'aaa' # line[5]= 'b' # => 'aaa b' # # @api public def []=(i, chars) edit_mode if i.is_a?(Range) @text[i] = chars @cursor += chars.length return end if i <= 0 before_text = '' after_text = @text.dup elsif i > @text.length - 1 # insert outside of line input before_text = @text.dup after_text = ?\s * (i - @text.length) @cursor += after_text.length else before_text = @text[0..i-1].dup after_text = @text[i..-1].dup end if i > @text.length - 1 @text = before_text + after_text + chars else @text = before_text + chars + after_text end @cursor = i + chars.length end # Read character # # @api public def [](i) @text[i] end # Replace current line with new text # # @param [String] text # # @api public def replace(text) @text = text @cursor = @text.length # put cursor outside of text replace_mode end # Insert char(s) at cursor position # # @api public def insert(chars) self[@cursor] = chars end # Add char and move cursor # # @api public def <<(char) @text << char @cursor += 1 end # Remove char from the line at current position # # @api public def delete(n = 1) @text.slice!(@cursor, n) end # Remove char from the line in front of the cursor # # @param [Integer] n # the number of chars to remove # # @api public def remove(n = 1) left(n) @text.slice!(@cursor, n) end # Full line with prompt as string # # @api public def to_s "#{@prompt}#{@text}" end alias inspect to_s # Prompt size # # @api public def prompt_size p = self.class.sanitize(@prompt).split(/\r?\n/) # return the length of each line + screen width for every line past the first # which accounts for multi-line prompts p.join.length + ((p.length - 1) * TTY::Screen.width ) end # Text size # # @api public def text_size self.class.sanitize(@text).size end # Full line size with prompt # # @api public def size prompt_size + text_size end alias length size end # Line end # Reader end # TTY tty-reader-0.7.0/lib/tty/reader/keys.rb0000644000175000017500000001077313621165543017604 0ustar gabstergabster# frozen_string_literal: true module TTY class Reader # Mapping of escape codes to keys module Keys def ctrl_keys { ?\C-a => :ctrl_a, ?\C-b => :ctrl_b, ?\C-c => :ctrl_c, ?\C-d => :ctrl_d, ?\C-e => :ctrl_e, ?\C-f => :ctrl_f, ?\C-g => :ctrl_g, ?\C-h => :ctrl_h, # identical to '\b' ?\C-i => :ctrl_i, # identical to '\t' ?\C-j => :ctrl_j, # identical to '\n' ?\C-k => :ctrl_k, ?\C-l => :ctrl_l, ?\C-m => :ctrl_m, # identical to '\r' ?\C-n => :ctrl_n, ?\C-o => :ctrl_o, ?\C-p => :ctrl_p, ?\C-q => :ctrl_q, ?\C-r => :ctrl_r, ?\C-s => :ctrl_s, ?\C-t => :ctrl_t, ?\C-u => :ctrl_u, ?\C-v => :ctrl_v, ?\C-w => :ctrl_w, ?\C-x => :ctrl_x, ?\C-y => :ctrl_y, ?\C-z => :ctrl_z, ?\C-@ => :ctrl_space, ?\C-| => :ctrl_backslash, # both Ctrl-| & Ctrl-\ ?\C-] => :ctrl_square_close, "\e[1;5A" => :ctrl_up, "\e[1;5B" => :ctrl_down, "\e[1;5C" => :ctrl_right, "\e[1;5D" => :ctrl_left } end module_function :ctrl_keys def keys { "\t" => :tab, "\n" => :enter, "\r" => :return, "\e" => :escape, " " => :space, "\x7F" => :backspace, "\e[1~" => :home, "\e[2~" => :insert, "\e[3~" => :delete, "\e[3;2~" => :shift_delete, "\e[3;5~" => :ctrl_delete, "\e[4~" => :end, "\e[5~" => :page_up, "\e[6~" => :page_down, "\e[7~" => :home, # xrvt "\e[8~" => :end, # xrvt "\e[A" => :up, "\e[B" => :down, "\e[C" => :right, "\e[D" => :left, "\e[E" => :clear, "\e[H" => :home, "\e[F" => :end, "\e[Z" => :back_tab, # shift + tab # xterm/gnome "\eOA" => :up, "\eOB" => :down, "\eOC" => :right, "\eOD" => :left, "\eOE" => :clear, "\eOF" => :end, "\eOH" => :home, "\eOP" => :f1, # xterm "\eOQ" => :f2, # xterm "\eOR" => :f3, # xterm "\eOS" => :f4, # xterm "\e[[A" => :f1, # linux "\e[[B" => :f2, # linux "\e[[C" => :f3, # linux "\e[[D" => :f4, # linux "\e[[E" => :f5, # linux "\e[11~" => :f1, # rxvt-unicode "\e[12~" => :f2, # rxvt-unicode "\e[13~" => :f3, # rxvt-unicode "\e[14~" => :f4, # rxvt-unicode "\e[15~" => :f5, "\e[17~" => :f6, "\e[18~" => :f7, "\e[19~" => :f8, "\e[20~" => :f9, "\e[21~" => :f10, "\e[23~" => :f11, "\e[24~" => :f12, "\e[25~" => :f13, "\e[26~" => :f14, "\e[28~" => :f15, "\e[29~" => :f16, "\e[31~" => :f17, "\e[32~" => :f18, "\e[33~" => :f19, "\e[34~" => :f20, # xterm "\e[1;2P" => :f13, "\e[2;2Q" => :f14, "\e[1;2S" => :f16, "\e[15;2~" => :f17, "\e[17;2~" => :f18, "\e[18;2~" => :f19, "\e[19;2~" => :f20, "\e[20;2~" => :f21, "\e[21;2~" => :f22, "\e[23;2~" => :f23, "\e[24;2~" => :f24, } end module_function :keys def win_keys { "\t" => :tab, "\n" => :enter, "\r" => :return, "\e" => :escape, " " => :space, "\b" => :backspace, [224, 71].pack('U*') => :home, [224, 79].pack('U*') => :end, [224, 82].pack('U*') => :insert, [224, 83].pack('U*') => :delete, [224, 73].pack('U*') => :page_up, [224, 81].pack('U*') => :page_down, [224, 72].pack('U*') => :up, [224, 80].pack('U*') => :down, [224, 77].pack('U*') => :right, [224, 75].pack('U*') => :left, [224, 83].pack('U*') => :clear, "\x00;" => :f1, "\x00<" => :f2, "\x00" => :f3, "\x00=" => :f4, "\x00?" => :f5, "\x00@" => :f6, "\x00A" => :f7, "\x00B" => :f8, "\x00C" => :f9, "\x00D" => :f10, "\x00\x85" => :f11, "\x00\x86" => :f12 } end module_function :win_keys end # Keys end # Reader end # TTY tty-reader-0.7.0/lib/tty/reader/key_event.rb0000644000175000017500000000256013621165543020615 0ustar gabstergabster# frozen_string_literal: true require_relative 'keys' module TTY class Reader # Responsible for meta-data information about key pressed # # @api private class Key < Struct.new(:name, :ctrl, :meta, :shift) def initialize(*) super(nil, false, false, false) end end # Represents key event emitted during keyboard press # # @api public class KeyEvent < Struct.new(:key, :value, :line) # Create key event from read input codes # # @param [Hash[Symbol]] keys # the keys and codes mapping # @param [Array[Integer]] codes # # @return [KeyEvent] # # @api public def self.from(keys, char, line = '') key = Key.new key.name = (name = keys[char]) ? name : :ignore case char when proc { |c| c =~ /^[a-z]{1}$/ } key.name = :alpha when proc { |c| c =~ /^[A-Z]{1}$/ } key.name = :alpha key.shift = true when proc { |c| c =~ /^\d+$/ } key.name = :num when proc { |cs| !Keys.ctrl_keys[cs].nil? } key.ctrl = true end new(key, char, line) end # Check if key event can be triggered # # @return [Boolean] # # @api public def trigger? !key.nil? && !key.name.nil? end end # KeyEvent end # Reader end # TTY tty-reader-0.7.0/lib/tty/reader/history.rb0000644000175000017500000000646313621165543020333 0ustar gabstergabster# frozen_string_literal: true require 'forwardable' module TTY class Reader # A class responsible for storing a history of all lines entered by # user when interacting with shell prompt. # # @api private class History include Enumerable extend Forwardable # Default maximum size DEFAULT_SIZE = 32 << 4 def_delegators :@history, :size, :length, :to_s, :inspect # Set and retrieve the maximum size of the buffer attr_accessor :max_size attr_reader :index attr_accessor :cycle attr_accessor :duplicates attr_accessor :exclude # Create a History buffer # # param [Integer] max_size # the maximum size for history buffer # # param [Hash[Symbol]] options # @option options [Boolean] :cycle # whether or not the history should cycle, false by default # @option options [Boolean] :duplicates # whether or not to store duplicates, true by default # @option options [Boolean] :exclude # a Proc to exclude items from storing in history # # @api public def initialize(max_size = DEFAULT_SIZE, **options) @max_size = max_size @index = nil @history = [] @duplicates = options.fetch(:duplicates) { true } @exclude = options.fetch(:exclude) { proc {} } @cycle = options.fetch(:cycle) { false } yield self if block_given? end # Iterates over history lines # # @api public def each if block_given? @history.each { |line| yield line } else @history.to_enum end end # Add the last typed line to history buffer # # @param [String] line # # @api public def push(line) @history.delete(line) unless @duplicates return if line.to_s.empty? || @exclude[line] @history.shift if size >= max_size @history << line @index = @history.size - 1 self end alias << push # Move the pointer to the next line in the history # # @api public def next return if size.zero? if @index == size - 1 @index = 0 if @cycle else @index += 1 end end def next? size > 0 && !(@index == size - 1 && !@cycle) end # Move the pointer to the previous line in the history def previous return if size.zero? if @index.zero? @index = size - 1 if @cycle else @index -= 1 end end def previous? size > 0 && !(@index < 0 && !@cycle) end # Return line at the specified index # # @raise [IndexError] index out of range # # @api public def [](index) if index < 0 index += @history.size if index < 0 end line = @history[index] if line.nil? raise IndexError, 'invalid index' end line.dup end # Get current line # # @api public def get return if size.zero? self[@index] end # Empty all history lines # # @api public def clear @history.clear @index = 0 end end # History end # Reader end # TTY tty-reader-0.7.0/lib/tty/reader/console.rb0000644000175000017500000000236213621165543020266 0ustar gabstergabster# frozen_string_literal: true require 'io/wait' require_relative 'keys' require_relative 'mode' module TTY class Reader class Console ESC = "\e".freeze CSI = "\e[".freeze TIMEOUT = 0.1 # Key codes # # @return [Hash[Symbol]] # # @api public attr_reader :keys # Escape codes # # @return [Array[Integer]] # # @api public attr_reader :escape_codes def initialize(input) @input = input @mode = Mode.new(input) @keys = Keys.ctrl_keys.merge(Keys.keys) @escape_codes = [[ESC.ord], CSI.bytes.to_a] end # Get a character from console with echo # # @param [Hash[Symbol]] options # @option options [Symbol] :echo # the echo toggle # # @return [String] # # @api private def get_char(options) mode.raw(options[:raw]) do mode.echo(options[:echo]) do if options[:nonblock] input.wait_readable(TIMEOUT) ? input.getc : nil else input.getc end end end end protected attr_reader :mode attr_reader :input end # Console end # Reader end # TTY tty-reader-0.7.0/lib/tty/reader.rb0000644000175000017500000002677713621165543016644 0ustar gabstergabster# frozen_string_literal: true require 'tty-cursor' require 'tty-screen' require 'wisper' require_relative 'reader/history' require_relative 'reader/line' require_relative 'reader/key_event' require_relative 'reader/console' require_relative 'reader/win_console' require_relative 'reader/version' module TTY # A class responsible for reading character input from STDIN # # Used internally to provide key and line reading functionality # # @api public class Reader include Wisper::Publisher # Raised when the user hits the interrupt key(Control-C) # # @api public InputInterrupt = Class.new(Interrupt) # Check if Windowz mode # # @return [Boolean] # # @api public def self.windows? ::File::ALT_SEPARATOR == '\\' end attr_reader :input attr_reader :output attr_reader :env attr_reader :track_history alias track_history? track_history attr_reader :console attr_reader :cursor # Key codes CARRIAGE_RETURN = 13 NEWLINE = 10 BACKSPACE = 8 DELETE = 127 # Initialize a Reader # # @param [IO] input # the input stream # @param [IO] output # the output stream # @param [Hash] options # @option options [Symbol] :interrupt # handling of Ctrl+C key out of :signal, :exit, :noop # @option options [Boolean] :track_history # disable line history tracking, true by default # # @api public def initialize(**options) @input = options.fetch(:input) { $stdin } @output = options.fetch(:output) { $stdout } @interrupt = options.fetch(:interrupt) { :error } @env = options.fetch(:env) { ENV } @track_history = options.fetch(:track_history) { true } @history_cycle = options.fetch(:history_cycle) { false } exclude_proc = ->(line) { line.strip == '' } @history_exclude = options.fetch(:history_exclude) { exclude_proc } @history_duplicates = options.fetch(:history_duplicates) { false } @console = select_console(input) @history = History.new do |h| h.cycle = @history_cycle h.duplicates = @history_duplicates h.exclude = @history_exclude end @stop = false # gathering input @cursor = TTY::Cursor subscribe(self) end alias old_subcribe subscribe # Subscribe to receive key events # # @example # reader.subscribe(MyListener.new) # # @return [self|yield] # # @api public def subscribe(listener, options = {}) old_subcribe(listener, options) object = self if block_given? object = yield unsubscribe(listener) end object end # Unsubscribe from receiving key events # # @example # reader.unsubscribe(my_listener) # # @return [void] # # @api public def unsubscribe(listener) registry = send(:local_registrations) registry.each do |object| if object.listener.equal?(listener) registry.delete(object) end end end # Select appropriate console # # @api private def select_console(input) if self.class.windows? && !env['TTY_TEST'] WinConsole.new(input) else Console.new(input) end end # Get input in unbuffered mode. # # @example # unbufferred do # ... # end # # @api public def unbufferred(&block) bufferring = output.sync # Immediately flush output output.sync = true block[] if block_given? ensure output.sync = bufferring end # Read a keypress including invisible multibyte codes # and return a character as a string. # Nothing is echoed to the console. This call will block for a # single keypress, but will not wait for Enter to be pressed. # # @param [Hash[Symbol]] options # @option options [Boolean] echo # whether to echo chars back or not, defaults to false # @option options [Boolean] raw # whenther raw mode enabled, defaults to true # # @return [String] # # @api public def read_keypress(options = {}) opts = { echo: false, raw: true }.merge(options) codes = unbufferred { get_codes(opts) } char = codes ? codes.pack('U*') : nil trigger_key_event(char) if char char end alias read_char read_keypress # Get input code points # # @param [Hash[Symbol]] options # @param [Array[Integer]] codes # # @return [Array[Integer]] # # @api private def get_codes(options = {}, codes = []) opts = { echo: true, raw: false }.merge(options) char = console.get_char(opts) handle_interrupt if console.keys[char] == :ctrl_c return if char.nil? codes << char.ord condition = proc { |escape| (codes - escape).empty? || (escape - codes).empty? && !(64..126).cover?(codes.last) } while console.escape_codes.any?(&condition) char_codes = get_codes(options.merge(nonblock: true), codes) break if char_codes.nil? end codes end # Get a single line from STDIN. Each key pressed is echoed # back to the shell. The input terminates when enter or # return key is pressed. # # @param [String] prompt # the prompt to display before input # # @param [String] value # the value to pre-populate line with # # @param [Boolean] echo # if true echo back characters, output nothing otherwise # # @return [String] # # @api public def read_line(prompt = '', **options) opts = { echo: true, raw: true }.merge(options) value = options.fetch(:value, '') line = Line.new(value, prompt: prompt) screen_width = TTY::Screen.width output.print(line) while (codes = get_codes(opts)) && (code = codes[0]) char = codes.pack('U*') if [:ctrl_d, :ctrl_z].include?(console.keys[char]) trigger_key_event(char, line: line.to_s) break end if opts[:raw] && opts[:echo] clear_display(line, screen_width) end if console.keys[char] == :backspace || BACKSPACE == code if !line.start? line.left line.delete end elsif console.keys[char] == :delete || DELETE == code line.delete elsif console.keys[char].to_s =~ /ctrl_/ # skip elsif console.keys[char] == :up line.replace(history_previous) if history_previous? elsif console.keys[char] == :down line.replace(history_next? ? history_next : '') elsif console.keys[char] == :left line.left elsif console.keys[char] == :right line.right elsif console.keys[char] == :home line.move_to_start elsif console.keys[char] == :end line.move_to_end else if opts[:raw] && code == CARRIAGE_RETURN char = "\n" line.move_to_end end line.insert(char) end if (console.keys[char] == :backspace || BACKSPACE == code) && opts[:echo] if opts[:raw] output.print("\e[1X") unless line.start? else output.print(?\s + (line.start? ? '' : ?\b)) end end # trigger before line is printed to allow for line changes trigger_key_event(char, line: line.to_s) if opts[:raw] && opts[:echo] output.print(line.to_s) if char == "\n" line.move_to_start elsif !line.end? # readjust cursor position output.print(cursor.backward(line.text_size - line.cursor)) end end if [CARRIAGE_RETURN, NEWLINE].include?(code) output.puts unless opts[:echo] break end end if track_history? && opts[:echo] add_to_history(line.text.rstrip) end line.text end # Clear display for the current line input # # Handles clearing input that is longer than the current # terminal width which allows copy & pasting long strings. # # @param [Line] line # the line to display # @param [Number] screen_width # the terminal screen width # # @api private def clear_display(line, screen_width) total_lines = count_screen_lines(line.size, screen_width) current_line = count_screen_lines(line.prompt_size + line.cursor, screen_width) lines_down = total_lines - current_line output.print(cursor.down(lines_down)) unless lines_down.zero? output.print(cursor.clear_lines(total_lines)) end # Count the number of screen lines given line takes up in terminal # # @param [Integer] line_or_size # the current line or its length # @param [Integer] screen_width # the width of terminal screen # # @return [Integer] # # @api public def count_screen_lines(line_or_size, screen_width = TTY::Screen.width) line_size = if line_or_size.is_a?(Integer) line_or_size else Line.sanitize(line_or_size).size end # new character + we don't want to add new line on screen_width new_chars = self.class.windows? ? -1 : 1 1 + [0, (line_size - new_chars) / screen_width].max end # Read multiple lines and return them in an array. # Skip empty lines in the returned lines array. # The input gathering is terminated by Ctrl+d or Ctrl+z. # # @param [String] prompt # the prompt displayed before the input # # @yield [String] line # # @return [Array[String]] # # @api public def read_multiline(*args) @stop = false lines = [] loop do line = read_line(*args) break if !line || line == '' next if line !~ /\S/ && !@stop if block_given? yield(line) unless line.to_s.empty? else lines << line unless line.to_s.empty? end break if @stop end lines end alias read_lines read_multiline # Expose event broadcasting # # @api public def trigger(event, *args) publish(event, *args) end # Capture Ctrl+d and Ctrl+z key events # # @api private def keyctrl_d(*) @stop = true end alias keyctrl_z keyctrl_d def add_to_history(line) @history.push(line) end def history_next? @history.next? end def history_next @history.next @history.get end def history_previous? @history.previous? end def history_previous line = @history.get @history.previous line end # Inspect class name and public attributes # @return [String] # # @api public def inspect "#<#{self.class}: @input=#{input}, @output=#{output}>" end private # Publish event # # @param [String] char # the key pressed # # @return [nil] # # @api private def trigger_key_event(char, line: '') event = KeyEvent.from(console.keys, char, line) trigger(:"key#{event.key.name}", event) if event.trigger? trigger(:keypress, event) end # Handle input interrupt based on provided value # # @api private def handle_interrupt case @interrupt when :signal Process.kill('SIGINT', Process.pid) when :exit exit(130) when Proc @interrupt.call when :noop return else raise InputInterrupt end end end # Reader end # TTY tty-reader-0.7.0/lib/tty-reader.rb0000644000175000017500000000003613621165543016616 0ustar gabstergabsterrequire_relative 'tty/reader' tty-reader-0.7.0/examples/0000755000175000017500000000000013621165543015262 5ustar gabstergabstertty-reader-0.7.0/examples/shell.rb0000644000175000017500000000035213621165543016716 0ustar gabstergabsterrequire_relative '../lib/tty-reader' puts "*** TTY::Reader Shell ***" puts "Press Ctrl-X or ESC to exit" reader = TTY::Reader.new reader.on(:keyctrl_x, :keyescape) { puts "Exiting..."; exit } loop do reader.read_line('=> ') end tty-reader-0.7.0/examples/noecho.rb0000644000175000017500000000020713621165543017061 0ustar gabstergabsterrequire_relative '../lib/tty-reader' reader = TTY::Reader.new answer = reader.read_line('=> ', echo: false) puts "Answer: #{answer}" tty-reader-0.7.0/examples/multiline.rb0000644000175000017500000000020213621165543017603 0ustar gabstergabsterrequire_relative '../lib/tty-reader' reader = TTY::Reader.new answer = reader.read_multiline(">> ") puts "\nanswer: #{answer}" tty-reader-0.7.0/examples/multi_prompt.rb0000644000175000017500000000026113621165543020341 0ustar gabstergabsterrequire_relative "../lib/tty-reader" reader = TTY::Reader.new reader.on(:keyctrl_x, :keyescape) { puts "Exiting..."; exit } loop do reader.read_line("one\ntwo\nthree") end tty-reader-0.7.0/examples/line.rb0000644000175000017500000000017313621165543016537 0ustar gabstergabsterrequire_relative '../lib/tty-reader' reader = TTY::Reader.new answer = reader.read_line(">> ") puts "answer: #{answer}" tty-reader-0.7.0/examples/keypress_nonblock.rb0000644000175000017500000000053313621165543021342 0ustar gabstergabsterrequire_relative '../lib/tty-reader' reader = TTY::Reader.new puts "Press a key (or Ctrl-X to exit)" loop do print reader.cursor.clear_line print "=> " char = reader.read_keypress(nonblock: true) if ?\C-x == char puts "Exiting..." exit elsif char puts "#{char.inspect} [#{char.ord}] (hex: #{char.ord.to_s(16)})" end end tty-reader-0.7.0/examples/keypress.rb0000644000175000017500000000044413621165543017456 0ustar gabstergabsterrequire_relative '../lib/tty-reader' reader = TTY::Reader.new puts "Press a key (or Ctrl-X to exit)" loop do print "=> " char = reader.read_keypress if ?\C-x == char puts "Exiting..." exit else puts "#{char.inspect} [#{char.ord}] (hex: #{char.ord.to_s(16)})" end end tty-reader-0.7.0/bin/0000755000175000017500000000000013621165543014214 5ustar gabstergabstertty-reader-0.7.0/bin/setup0000755000175000017500000000020313621165543015275 0ustar gabstergabster#!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' set -vx bundle install # Do any other automated setup that you need to do here tty-reader-0.7.0/bin/console0000755000175000017500000000014413621165543015603 0ustar gabstergabster#!/usr/bin/env ruby require "bundler/setup" require "tty/reader" require "irb" IRB.start(__FILE__) tty-reader-0.7.0/benchmarks/0000755000175000017500000000000013621165543015561 5ustar gabstergabstertty-reader-0.7.0/benchmarks/speed_read_line.rb0000644000175000017500000000142313621165543021210 0ustar gabstergabsterrequire 'benchmark/ips' require 'tty-reader' input = StringIO.new("abc\n") output = StringIO.new $stdin = input reader = TTY::Reader.new(input, output) Benchmark.ips do |x| x.report('gets') do input.rewind $stdin.gets end x.report('read_line') do input.rewind reader.read_line end x.compare! end # v0.1.0 # # Calculating ------------------------------------- # gets 51729 i/100ms # read_line 164 i/100ms # ------------------------------------------------- # gets 1955255.2 (±3.7%) i/s - 9776781 in 5.008004s # read_line 1215.1 (±33.1%) i/s - 5248 in 5.066569s # # Comparison: # gets: 1955255.2 i/s # read_line: 1215.1 i/s - 1609.19x slower tty-reader-0.7.0/benchmarks/speed_read_char.rb0000644000175000017500000000141513621165543021177 0ustar gabstergabsterrequire 'benchmark/ips' require 'tty-reader' input = StringIO.new("a") output = StringIO.new $stdin = input reader = TTY::Reader.new(input, output) Benchmark.ips do |x| x.report('getc') do input.rewind $stdin.getc end x.report('read_char') do input.rewind reader.read_char end x.compare! end # v0.1.0 # # Calculating ------------------------------------- # getc 52462 i/100ms # read_char 751 i/100ms # ------------------------------------------------- # getc 2484819.4 (±4.1%) i/s - 12433494 in 5.013438s # read_char 7736.4 (±2.9%) i/s - 39052 in 5.052628s # # Comparison: # getc: 2484819.4 i/s # read_char: 7736.4 i/s - 321.19x slower tty-reader-0.7.0/Rakefile0000644000175000017500000000024213621165543015107 0ustar gabstergabster# encoding: utf-8 require "bundler/gem_tasks" FileList['tasks/**/*.rake'].each(&method(:import)) desc 'Run all specs' task ci: %w[ spec ] task default: :spec tty-reader-0.7.0/README.md0000644000175000017500000002712313621165543014730 0ustar gabstergabster
tty logo
# TTY::Reader [![Gitter](https://badges.gitter.im/Join%20Chat.svg)][gitter] [![Gem Version](https://badge.fury.io/rb/tty-reader.svg)][gem] [![Build Status](https://secure.travis-ci.org/piotrmurach/tty-reader.svg?branch=master)][travis] [![Build status](https://ci.appveyor.com/api/projects/status/cj4owy2vlty2q1ko?svg=true)][appveyor] [![Maintainability](https://api.codeclimate.com/v1/badges/2f68d5e8ecc271bda820/maintainability)][codeclimate] [![Coverage Status](https://coveralls.io/repos/github/piotrmurach/tty-reader/badge.svg)][coverage] [![Inline docs](http://inch-ci.org/github/piotrmurach/tty-reader.svg?branch=master)][inchpages] [gitter]: https://gitter.im/piotrmurach/tty [gem]: http://badge.fury.io/rb/tty-reader [travis]: http://travis-ci.org/piotrmurach/tty-reader [appveyor]: https://ci.appveyor.com/project/piotrmurach/tty-reader [codeclimate]: https://codeclimate.com/github/piotrmurach/tty-reader/maintainability [coverage]: https://coveralls.io/github/piotrmurach/tty-reader [inchpages]: http://inch-ci.org/github/piotrmurach/tty-reader > A pure Ruby library that provides a set of methods for processing keyboard input in character, line and multiline modes. It maintains history of entered input with an ability to recall and re-edit those inputs. It lets you register to listen for keystroke events and trigger custom key events yourself. **TTY::Reader** provides independent reader component for [TTY](https://github.com/piotrmurach/tty) toolkit. ![](assets/shell.gif) ## Compatibility The `tty-reader` is not compatible with the GNU Readline and doesn't aim to be. It originated from [tty-prompt](https://github.com/piotrmurach/tty-prompt) project to provide flexibility, independence from underlying operating system and Ruby like API interface for creating different prompts. `TTY::Reader` forges its own path to provide features necessary for building line editing in terminal applications! ## Features * Pure Ruby * Reading [single keypress](#21-read_keypress) * [Line editing](#22-read_line) * Reading [multiline input](#23-read_multiline) * Ability to [register](#24-on) for keystroke events * Track input [history](#32-track_history) * No global state * Works on Linux, OS X, FreeBSD and Windows * Supports Ruby versions `>= 2.0.0` & JRuby ## Installation Add this line to your application's Gemfile: ```ruby gem 'tty-reader' ``` And then execute: $ bundle Or install it yourself as: $ gem install tty-reader * [1. Usage](#1-usage) * [2. API](#2-api) * [2.1 read_keypress](#21-read_keypress) * [2.2 read_line](#22-read_line) * [2.3 read_multiline](#23-read_multiline) * [2.4 on](#24-on) * [2.5 subscribe](#25-subscribe) * [2.6 unsubscribe](#26-unsubscribe) * [2.7 trigger](#27-trigger) * [2.8 supported events](#28-supported-events) * [3. Configuration](#3-configuration) * [3.1 :interrupt](#31-interrupt) * [3.2 :track_history](#32-track_history) * [3.3 :history_cycle](#33-history_cycle) * [3.4 :history_duplicates](#34-history_duplicates) * [3.5 :history_exclude](#35-history_exclude) ## Usage In just a few lines you can recreate IRB prompt. Initialize the reader: ```ruby reader = TTY::Reader.new ``` Then register to listen for key events, in this case listen for `Ctrl-X` or `Esc` keys to exit: ```ruby reader.on(:keyctrl_x, :keyescape) do puts "Exiting..." exit end ``` Finally, keep asking user for line input with a `=>` as a prompt: ```ruby loop do reader.read_line('=> ') end ``` ## API ### 2.1 read_keypress To read a single key stroke from the user use `read_char` or `read_keypress`: ```ruby reader.read_char reader.read_keypress ``` ### 2.2 read_line By default `read_line` works in `raw mode` which means it behaves like a line editor that allows you to edit each character, respond to `control characters` such as `Control-A` to `Control-B` or navigate through history. For example, to read a single line terminated by a new line character use `read_line` like so: ```ruby reader.read_line ``` If you wish for the keystrokes to be interpreted by the terminal instead, use so called `cooked` mode by providing the `:raw` option set to `false`: ```ruby reader.read_line(raw: false) ``` Any non-interpreted characters received are written back to terminal, however you can stop this by using `:echo` option set to `false`: ```ruby reader.read_line(echo: false) ``` You can also provide a line prefix displayed before input by passing it as a first argument: ```ruby reader.read_line(">> ") # >> ``` To pre-populate the line content for editing use `:value` option: ```ruby reader.read_line("> ", value: "edit me") # > edit me ``` ### 2.3 read_multiline By default `read_multiline` works in `raw mode` which means it behaves like a multiline editor that allows you to edit each character, respond to `control characters` such as `Control-A` to `Control-B` or navigate through history. For example, to read more than one line terminated by `Ctrl+d` or `Ctrl+z` use `read_multiline`: ```ruby reader.read_multiline # => [ "line1", "line2", ... ] ``` If you wish for the keystrokes to be interpreted by the terminal instead, use so called `cooked` mode by providing the `:raw` option set to `false`: ```ruby reader.read_line(raw: false) ``` You can also provide a line prefix displayed before input by passing a string as a first argument: ```ruby reader.read_multiline(">> ") ``` ### 2.4 on You can register to listen on a key pressed events. This can be done by calling `on` with a event name(s): ```ruby reader.on(:keypress) { |event| .... } ``` or listen for multiple events: ```ruby reader.on(:keyctrl_x, :keyescape) { |event| ... } ``` The `KeyEvent` object is yielded to a block whenever a particular key event fires. The event responds to: * `key` - key pressed * `value` - value of the key pressed * `line` - the content of the currently edited line, empty otherwise The `value` returns the actual key pressed and the `line` the content for the currently edited line or is empty. The `key` is an object that responds to following messages: * `name` - the name of the event such as :up, :down, letter or digit * `meta` - true if event is non-standard key associated * `shift` - true if shift has been pressed with the key * `ctrl` - true if ctrl has been pressed with the key For example, to add listen to vim like navigation keys, one would do the following: ```ruby reader.on(:keypress) do |event| if event.value == 'j' ... end if event.value == 'k' ... end end ``` You can subscribe to more than one event: ```ruby prompt.on(:keypress) { |key| ... } .on(:keydown) { |key| ... } ``` ### 2.5 subscribe You can subscribe any object to listen for the emitted [key events](#27-supported-events) using the `subscribe` message. The listener would need to implement a method for every event it wishes to receive. For example, if a `MyListener` class wishes to only listen for `keypress` event: ```ruby class MyListener def keypress(event) ... end end ``` Then subscribing is done: ```ruby reader.subscribe(MyListener.new) ``` Alternatively, `subscribe` allows you to listen to events only for the duration of block execution like so: ```ruby reader.subscribe(MyListener) do ... end ``` ### 2.6 unsubscribe You can unsubscribe any object from listening to the key events using the `unsubscribe` message: ```ruby reader.unsubscribe(my_listener) ``` ### 2.7 trigger The signature for triggering key events is `trigger(event, args...)`. The first argument is a [key event name](#27-supported-events) followed by any number of actual values related to the event being triggered. For example, to trigger `:keydown` event do: ```ruby reader.trigger(:keydown) ``` To add vim bindings for line editing you could discern between alphanumeric inputs like so: ```ruby reader.on(:keypress) do |event| if event.value == 'j' reader.trigger(:keydown) end if evevnt.value == 'k' reader.trigger(:keyup) end end ``` ### 2.8 supported events The available key events for character input are: * `:keypress` * `:keyenter` * `:keyreturn` * `:keytab` * `:keybackspace` * `:keyspace` * `:keyescape` * `:keydelete` * `:keyalpha` * `:keynum` The navigation related key events are: * `:keydown` * `:keyup` * `:keyleft` * `:keyright` * `:keyhome` * `:keyend` * `:keyclear` The specific `ctrl` key events: * `:keyctrl_a` * `:keyctrl_b` * ... * `:keyctrl_z` The key events for functional keys `f*` are: * `:keyf1` * `:keyf2` * ... * `:keyf24` ## 3. Configuration ### 3.1. `:interrupt` By default `InputInterrupt` error will be raised when the user hits the interrupt key(Control-C). However, you can customise this behaviour by passing the `:interrupt` option. The available options are: * `:signal` - sends interrupt signal * `:exit` - exists with status code * `:noop` - skips handler * custom proc For example, to send interrupt signal do: ```ruby reader = TTY::Reader.new(interrupt: :signal) ``` ### 3.2. `:track_history` The `read_line` and `read_multiline` provide history buffer that tracks all the lines entered during `TTY::Reader.new` interactions. The history buffer provides previous or next lines when user presses up/down arrows respectively. However, if you wish to disable this behaviour use `:track_history` option like so: ```ruby reader = TTY::Reader.new(track_history: false) ``` ### 3.3. `:history_cycle` This option determines whether the history buffer allows for infinite navigation. By default it is set to `false`. You can change this: ```ruby reader = TTY::Reader.new(history_cycle: true) ``` ### 3.4. `:history_duplicates` This option controls whether duplicate lines are stored in history. By default set to `true`. You can change this: ```ruby reader = TTY::Reader.new(history_duplicates: false) ``` ### 3.5. `:history_exclude` This option allows you to exclude lines from being stored in history. It accepts a `Proc` with a line as a first argument. By default it is set to exclude empty lines. To change this: ```ruby reader = TTY::Reader.new(history_exclude: ->(line) { ... }) ``` ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/piotrmurach/tty-reader. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 1. Clone the project on GitHub 2. Create a feature branch 3. Submit a Pull Request Important notes: - **All new features must include test coverage.** At a bare minimum, unit tests are required. It is preferred if you include acceptance tests as well. - **The tests must be be idempotent.** Any test run should produce the same result when run over and over. - **All new features must include source code & readme documentation** Any new method you add should include yarddoc style documentation with clearly specified parameter and return types. ## License The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). ## Code of Conduct Everyone interacting in the TTY::Reader project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/piotrmurach/tty-reader/blob/master/CODE_OF_CONDUCT.md). ## Copyright Copyright (c) 2017 Piotr Murach. See LICENSE for further details. tty-reader-0.7.0/LICENSE.txt0000644000175000017500000000206713621165543015274 0ustar gabstergabsterThe MIT License (MIT) Copyright (c) 2017 Piotr Murach Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. tty-reader-0.7.0/CHANGELOG.md0000644000175000017500000000523713621165543015264 0ustar gabstergabster# Change log ## [v0.7.0] - 2019-11-24 ### Added * Add support for a multi-line prompt by Katelyn Schiesser(@slowbro) * Add metadata to gemspec ## [v0.6.0] - 2019-05-27 ### Added * Add :value option to #read_line to allow pre-populating of line content ### Changed * Change to make InputInterrupt to derive from Interrupt by Samuel Williams(@ioquatix) * Change #read_line to trigger before line is printed to allow for line changes in key callbacks * Change Console#get_char :nonblock option to wait for readable input without blocking * Change to remove bundler version constraints * Change to update tty-screen dependency * Change to update tty-cursor dependency ## [v0.5.0] - 2018-11-24 ### Added * Add KeyEvent#line to expose current line in key event callbacks ### Fixed * Fix Esc key by differentiating between escaped keys and actual escape input * Fix line editing to correctly insert next to last character ## [v0.4.0] - 2018-08-05 ### Changed * Change to update tty-screen & tty-cursor dependencies ## [v0.3.0] - 2018-04-29 ### Added * Add Reader#unsubscribe to allow stop listening to local key events ### Changed * Change Reader#subscribe to allow to listening for key events only inside a block * Change to group xterm keys for navigation ## [v0.2.0] - 2018-01-01 ### Added * Add home & end keys support in #read_line * Add tty-screen & tty-cursor dependencies ### Changed * Change Codes to Keys and inverse keys lookup to allow for different system keys matching same name. * Change Reader#initialize to only accept options and make input and output options as well. * Change #read_line to print newline character in noecho mode * Change Reader::Line to include prompt prefix * Change Reader#initialize to only accept options in place of positional arguments * Change Reader to expose history options ### Fixed * Fix issues with recognising :home & :end keys on different terminals * Fix #read_line to work with strings spanning multiple screen widths and allow copy-pasting a long string without repeating prompt * Fix backspace keystroke in cooked mode * Fix history to only save lines in echo mode ## [v0.1.0] - 2017-08-30 * Initial implementation and release [v0.7.0]: https://github.com/piotrmurach/tty-reader/compare/v0.6.0...v0.7.0 [v0.6.0]: https://github.com/piotrmurach/tty-reader/compare/v0.5.0...v0.6.0 [v0.5.0]: https://github.com/piotrmurach/tty-reader/compare/v0.4.0...v0.5.0 [v0.4.0]: https://github.com/piotrmurach/tty-reader/compare/v0.3.0...v0.4.0 [v0.3.0]: https://github.com/piotrmurach/tty-reader/compare/v0.2.0...v0.3.0 [v0.2.0]: https://github.com/piotrmurach/tty-reader/compare/v0.1.0...v0.2.0 [v0.1.0]: https://github.com/piotrmurach/tty-reader/compare/v0.1.0