cli-ui 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +17 -0
- data/.travis.yml +5 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/Rakefile +20 -0
- data/bin/console +14 -0
- data/cli-ui.gemspec +27 -0
- data/dev.yml +14 -0
- data/lib/cli/ui.rb +163 -0
- data/lib/cli/ui/ansi.rb +155 -0
- data/lib/cli/ui/box.rb +15 -0
- data/lib/cli/ui/color.rb +79 -0
- data/lib/cli/ui/formatter.rb +178 -0
- data/lib/cli/ui/frame.rb +310 -0
- data/lib/cli/ui/glyph.rb +74 -0
- data/lib/cli/ui/progress.rb +90 -0
- data/lib/cli/ui/prompt.rb +156 -0
- data/lib/cli/ui/prompt/interactive_options.rb +171 -0
- data/lib/cli/ui/spinner.rb +48 -0
- data/lib/cli/ui/spinner/async.rb +40 -0
- data/lib/cli/ui/spinner/spin_group.rb +223 -0
- data/lib/cli/ui/stdout_router.rb +188 -0
- data/lib/cli/ui/terminal.rb +21 -0
- data/lib/cli/ui/version.rb +5 -0
- metadata +117 -0
data/lib/cli/ui/glyph.rb
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'cli/ui'
|
2
|
+
|
3
|
+
module CLI
|
4
|
+
module UI
|
5
|
+
class Glyph
|
6
|
+
class InvalidGlyphHandle < ArgumentError
|
7
|
+
def initialize(handle)
|
8
|
+
@handle = handle
|
9
|
+
end
|
10
|
+
|
11
|
+
def message
|
12
|
+
keys = Glyph.available.join(',')
|
13
|
+
"invalid glyph handle: #{@handle} " \
|
14
|
+
"-- must be one of CLI::UI::Glyph.available (#{keys})"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
attr_reader :handle, :codepoint, :color, :char, :to_s, :fmt
|
19
|
+
|
20
|
+
# Creates a new glyph
|
21
|
+
#
|
22
|
+
# ==== Attributes
|
23
|
+
#
|
24
|
+
# * +handle+ - The handle in the +MAP+ constant
|
25
|
+
# * +codepoint+ - The codepoint used to create the glyph (e.g. +0x2717+ for a ballot X)
|
26
|
+
# * +color+ - What color to output the glyph. Check +CLI::UI::Color+ for options.
|
27
|
+
#
|
28
|
+
def initialize(handle, codepoint, color)
|
29
|
+
@handle = handle
|
30
|
+
@codepoint = codepoint
|
31
|
+
@color = color
|
32
|
+
@char = [codepoint].pack('U')
|
33
|
+
@to_s = color.code + char + Color::RESET.code
|
34
|
+
@fmt = "{{#{color.name}:#{char}}}"
|
35
|
+
|
36
|
+
MAP[handle] = self
|
37
|
+
end
|
38
|
+
|
39
|
+
# Mapping of glyphs to terminal output
|
40
|
+
MAP = {}
|
41
|
+
# YELLOw SMALL STAR (⭑)
|
42
|
+
STAR = new('*', 0x2b51, Color::YELLOW)
|
43
|
+
# BLUE MATHEMATICAL SCRIPT SMALL i (𝒾)
|
44
|
+
INFO = new('i', 0x1d4be, Color::BLUE)
|
45
|
+
# BLUE QUESTION MARK (?)
|
46
|
+
QUESTION = new('?', 0x003f, Color::BLUE)
|
47
|
+
# GREEN CHECK MARK (✓)
|
48
|
+
CHECK = new('v', 0x2713, Color::GREEN)
|
49
|
+
# RED BALLOT X (✗)
|
50
|
+
X = new('x', 0x2717, Color::RED)
|
51
|
+
|
52
|
+
# Looks up a glyph by name
|
53
|
+
#
|
54
|
+
# ==== Raises
|
55
|
+
# Raises a InvalidGlyphHandle if the glyph is not available
|
56
|
+
# You likely need to create it with +.new+ or you made a typo
|
57
|
+
#
|
58
|
+
# ==== Returns
|
59
|
+
# Returns a terminal output-capable string
|
60
|
+
#
|
61
|
+
def self.lookup(name)
|
62
|
+
MAP.fetch(name.to_s)
|
63
|
+
rescue KeyError
|
64
|
+
raise InvalidGlyphHandle, name
|
65
|
+
end
|
66
|
+
|
67
|
+
# All available glyphs by name
|
68
|
+
#
|
69
|
+
def self.available
|
70
|
+
MAP.keys
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'cli/ui'
|
2
|
+
|
3
|
+
module CLI
|
4
|
+
module UI
|
5
|
+
class Progress
|
6
|
+
# A Cyan filled block
|
7
|
+
FILLED_BAR = "\e[46m"
|
8
|
+
# A bright white block
|
9
|
+
UNFILLED_BAR = "\e[1;47m"
|
10
|
+
|
11
|
+
# Add a progress bar to the terminal output
|
12
|
+
#
|
13
|
+
# https://user-images.githubusercontent.com/3074765/33799794-cc4c940e-dd00-11e7-9bdc-90f77ec9167c.gif
|
14
|
+
#
|
15
|
+
# ==== Example Usage:
|
16
|
+
#
|
17
|
+
# Set the percent to X
|
18
|
+
# CLI::UI::Progress.progress do |bar|
|
19
|
+
# bar.tick(set_percent: percent)
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# Increase the percent by 1 percent
|
23
|
+
# CLI::UI::Progress.progress do |bar|
|
24
|
+
# bar.tick
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# Increase the percent by X
|
28
|
+
# CLI::UI::Progress.progress do |bar|
|
29
|
+
# bar.tick(percent: 5)
|
30
|
+
# end
|
31
|
+
def self.progress(width: Terminal.width)
|
32
|
+
bar = Progress.new(width: width)
|
33
|
+
print CLI::UI::ANSI.hide_cursor
|
34
|
+
yield(bar)
|
35
|
+
ensure
|
36
|
+
puts bar.to_s
|
37
|
+
CLI::UI.raw do
|
38
|
+
print(ANSI.show_cursor)
|
39
|
+
puts(ANSI.previous_line + ANSI.end_of_line)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Initialize a progress bar. Typically used in a +Progress.progress+ block
|
44
|
+
#
|
45
|
+
# ==== Options
|
46
|
+
# One of the follow can be used, but not both together
|
47
|
+
#
|
48
|
+
# * +:width+ - The width of the terminal
|
49
|
+
#
|
50
|
+
def initialize(width: Terminal.width)
|
51
|
+
@percent_done = 0
|
52
|
+
@max_width = width
|
53
|
+
end
|
54
|
+
|
55
|
+
# Set the progress of the bar. Typically used in a +Progress.progress+ block
|
56
|
+
#
|
57
|
+
# ==== Options
|
58
|
+
# One of the follow can be used, but not both together
|
59
|
+
#
|
60
|
+
# * +:percent+ - Increment progress by a specific percent amount
|
61
|
+
# * +:set_percent+ - Set progress to a specific percent
|
62
|
+
#
|
63
|
+
def tick(percent: 0.01, set_percent: nil)
|
64
|
+
raise ArgumentError, 'percent and set_percent cannot both be specified' if percent != 0.01 && set_percent
|
65
|
+
@percent_done += percent
|
66
|
+
@percent_done = set_percent if set_percent
|
67
|
+
@percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
|
68
|
+
|
69
|
+
print to_s
|
70
|
+
print CLI::UI::ANSI.previous_line
|
71
|
+
print CLI::UI::ANSI.end_of_line + "\n"
|
72
|
+
end
|
73
|
+
|
74
|
+
# Format the progress bar to be printed to terminal
|
75
|
+
#
|
76
|
+
def to_s
|
77
|
+
suffix = " #{(@percent_done * 100).round(2)}%"
|
78
|
+
workable_width = @max_width - Frame.prefix_width - suffix.size
|
79
|
+
filled = (@percent_done * workable_width.to_f).ceil
|
80
|
+
unfilled = workable_width - filled
|
81
|
+
|
82
|
+
CLI::UI.resolve_text [
|
83
|
+
FILLED_BAR + ' ' * filled,
|
84
|
+
UNFILLED_BAR + ' ' * unfilled,
|
85
|
+
CLI::UI::Color::RESET.code + suffix
|
86
|
+
].join
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
require 'cli/ui'
|
2
|
+
require 'readline'
|
3
|
+
|
4
|
+
module CLI
|
5
|
+
module UI
|
6
|
+
module Prompt
|
7
|
+
autoload :InteractiveOptions, 'cli/ui/prompt/interactive_options'
|
8
|
+
private_constant :InteractiveOptions
|
9
|
+
|
10
|
+
class << self
|
11
|
+
# Ask a user a question with either free form answer or a set of answers
|
12
|
+
# Do not use this method for yes/no questions. Use +confirm+
|
13
|
+
# Can use arrows, y/n, numbers, and vim bindings to control
|
14
|
+
#
|
15
|
+
# * Handles free form answers (options are nil)
|
16
|
+
# * Handles default answers for free form text
|
17
|
+
# * Handles file auto completion for file input
|
18
|
+
# * Handles interactively choosing answers using +InteractiveOptions+
|
19
|
+
#
|
20
|
+
# https://user-images.githubusercontent.com/3074765/33799822-47f23302-dd01-11e7-82f3-9072a5a5f611.png
|
21
|
+
#
|
22
|
+
# ==== Attributes
|
23
|
+
#
|
24
|
+
# * +question+ - (required) The question to ask the user
|
25
|
+
#
|
26
|
+
# ==== Options
|
27
|
+
#
|
28
|
+
# * +:options+ - Options to ask the user. Will use +InteractiveOptions+ to do so
|
29
|
+
# * +:default+ - The default answer to the question (e.g. they just press enter and don't input anything)
|
30
|
+
# * +:is_file+ - Tells the input to use file auto-completion (tab completion)
|
31
|
+
# * +:allow_empty+ - Allows the answer to be empty
|
32
|
+
#
|
33
|
+
# Note:
|
34
|
+
# * +:options+ conflicts with +:default+ and +:is_file+, you cannot set options with either of these keywords
|
35
|
+
# * +:default+ conflicts with +:allow_empty:, you cannot set these together
|
36
|
+
#
|
37
|
+
# ==== Example Usage:
|
38
|
+
#
|
39
|
+
# Free form question
|
40
|
+
# CLI::UI::Prompt.ask('What color is the sky?')
|
41
|
+
#
|
42
|
+
# Free form question with a file answer
|
43
|
+
# CLI::UI::Prompt.ask('Where is your Gemfile located?', is_file: true)
|
44
|
+
#
|
45
|
+
# Free form question with a default answer
|
46
|
+
# CLI::UI::Prompt.ask('What color is the sky?', default: 'blue')
|
47
|
+
#
|
48
|
+
# Free form question when the answer can be empty
|
49
|
+
# CLI::UI::Prompt.ask('What is your opinion on this question?', allow_empty: true)
|
50
|
+
#
|
51
|
+
# Question with answers
|
52
|
+
# CLI::UI::Prompt.ask('What kind of project is this?', options: %w(rails go ruby python))
|
53
|
+
#
|
54
|
+
#
|
55
|
+
def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true)
|
56
|
+
if (default && !allow_empty) || (options && (default || is_file))
|
57
|
+
raise(ArgumentError, 'conflicting arguments')
|
58
|
+
end
|
59
|
+
|
60
|
+
if default
|
61
|
+
puts_question("#{question} (empty = #{default})")
|
62
|
+
elsif options
|
63
|
+
puts_question("#{question} {{yellow:(choose with ↑ ↓ ⏎)}}")
|
64
|
+
else
|
65
|
+
puts_question(question)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Present the user with options
|
69
|
+
if options
|
70
|
+
resp = InteractiveOptions.call(options)
|
71
|
+
|
72
|
+
# Clear the line, and reset the question to include the answer
|
73
|
+
print(ANSI.previous_line + ANSI.end_of_line + ' ')
|
74
|
+
print(ANSI.cursor_save)
|
75
|
+
print(' ' * CLI::UI::Terminal.width)
|
76
|
+
print(ANSI.cursor_restore)
|
77
|
+
puts_question("#{question} (You chose: {{italic:#{resp}}})")
|
78
|
+
|
79
|
+
return resp
|
80
|
+
end
|
81
|
+
|
82
|
+
# Ask a free form question
|
83
|
+
loop do
|
84
|
+
line = readline(is_file: is_file)
|
85
|
+
|
86
|
+
if line.empty? && default
|
87
|
+
write_default_over_empty_input(default)
|
88
|
+
return default
|
89
|
+
end
|
90
|
+
|
91
|
+
if !line.empty? || allow_empty
|
92
|
+
return line
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Asks the user a yes/no question.
|
98
|
+
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
|
99
|
+
#
|
100
|
+
# ==== Example Usage:
|
101
|
+
#
|
102
|
+
# Confirmation question
|
103
|
+
# CLI::UI::Prompt.confirm('Is the sky blue?')
|
104
|
+
#
|
105
|
+
def confirm(question)
|
106
|
+
ask(question, options: %w(yes no)) == 'yes'
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def write_default_over_empty_input(default)
|
112
|
+
CLI::UI.raw do
|
113
|
+
STDERR.puts(
|
114
|
+
CLI::UI::ANSI.cursor_up(1) +
|
115
|
+
"\r" +
|
116
|
+
CLI::UI::ANSI.cursor_forward(4) + # TODO: width
|
117
|
+
default +
|
118
|
+
CLI::UI::Color::RESET.code
|
119
|
+
)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def puts_question(str)
|
124
|
+
CLI::UI.with_frame_color(:blue) do
|
125
|
+
STDOUT.puts(CLI::UI.fmt('{{?}} ' + str))
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def readline(is_file: false)
|
130
|
+
if is_file
|
131
|
+
Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
|
132
|
+
Readline.completion_append_character = ""
|
133
|
+
else
|
134
|
+
Readline.completion_proc = proc { |*| nil }
|
135
|
+
Readline.completion_append_character = " "
|
136
|
+
end
|
137
|
+
|
138
|
+
# because Readline is a C library, CLI::UI's hooks into $stdout don't
|
139
|
+
# work. We could work around this by having CLI::UI use a pipe and a
|
140
|
+
# thread to manage output, but the current strategy feels like a
|
141
|
+
# better tradeoff.
|
142
|
+
prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
|
143
|
+
prompt = prefix + CLI::UI.fmt('{{blue:> }}{{yellow:')
|
144
|
+
|
145
|
+
begin
|
146
|
+
line = Readline.readline(prompt, true)
|
147
|
+
line.to_s.chomp
|
148
|
+
rescue Interrupt
|
149
|
+
CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
|
150
|
+
raise
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,171 @@
|
|
1
|
+
require 'io/console'
|
2
|
+
|
3
|
+
module CLI
|
4
|
+
module UI
|
5
|
+
module Prompt
|
6
|
+
class InteractiveOptions
|
7
|
+
# Prompts the user with options
|
8
|
+
# Uses an interactive session to allow the user to pick an answer
|
9
|
+
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
|
10
|
+
#
|
11
|
+
# https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
|
12
|
+
#
|
13
|
+
# ==== Example Usage:
|
14
|
+
#
|
15
|
+
# Ask an interactive question
|
16
|
+
# CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
|
17
|
+
#
|
18
|
+
def self.call(options)
|
19
|
+
list = new(options)
|
20
|
+
options[list.call - 1]
|
21
|
+
end
|
22
|
+
|
23
|
+
# Initializes a new +InteractiveOptions+
|
24
|
+
# Usually called from +self.call+
|
25
|
+
#
|
26
|
+
# ==== Example Usage:
|
27
|
+
#
|
28
|
+
# CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
|
29
|
+
#
|
30
|
+
def initialize(options)
|
31
|
+
@options = options
|
32
|
+
@active = 1
|
33
|
+
@marker = '>'
|
34
|
+
@answer = nil
|
35
|
+
@state = :root
|
36
|
+
end
|
37
|
+
|
38
|
+
# Calls the +InteractiveOptions+ and asks the question
|
39
|
+
# Usually used from +self.call+
|
40
|
+
#
|
41
|
+
def call
|
42
|
+
CLI::UI.raw { print(ANSI.hide_cursor) }
|
43
|
+
while @answer.nil?
|
44
|
+
render_options
|
45
|
+
wait_for_user_input
|
46
|
+
reset_position
|
47
|
+
end
|
48
|
+
clear_output
|
49
|
+
@answer
|
50
|
+
ensure
|
51
|
+
CLI::UI.raw do
|
52
|
+
print(ANSI.show_cursor)
|
53
|
+
puts(ANSI.previous_line + ANSI.end_of_line)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def reset_position
|
60
|
+
# This will put us back at the beginning of the options
|
61
|
+
# When we redraw the options, they will be overwritten
|
62
|
+
CLI::UI.raw do
|
63
|
+
num_lines.times { print(ANSI.previous_line) }
|
64
|
+
print(ANSI.previous_line + ANSI.end_of_line + "\n")
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def clear_output
|
69
|
+
CLI::UI.raw do
|
70
|
+
# Write over all lines with whitespace
|
71
|
+
num_lines.times { puts(' ' * CLI::UI::Terminal.width) }
|
72
|
+
end
|
73
|
+
reset_position
|
74
|
+
end
|
75
|
+
|
76
|
+
def num_lines
|
77
|
+
# @options will be an array of questions but each option can be multi-line
|
78
|
+
# so to get the # of lines, you need to join then split
|
79
|
+
joined_options = @options.join("\n")
|
80
|
+
joined_options.split("\n").reject(&:empty?).size
|
81
|
+
end
|
82
|
+
|
83
|
+
ESC = "\e"
|
84
|
+
|
85
|
+
def up
|
86
|
+
@active = @active - 1 >= 1 ? @active - 1 : @options.length
|
87
|
+
end
|
88
|
+
|
89
|
+
def down
|
90
|
+
@active = @active + 1 <= @options.length ? @active + 1 : 1
|
91
|
+
end
|
92
|
+
|
93
|
+
def select_n(n)
|
94
|
+
@active = n
|
95
|
+
@answer = n
|
96
|
+
end
|
97
|
+
|
98
|
+
def select_bool(char)
|
99
|
+
return unless (@options - %w(yes no)).empty?
|
100
|
+
opt = @options.detect { |o| o.start_with?(char) }
|
101
|
+
@active = @options.index(opt) + 1
|
102
|
+
@answer = @options.index(opt) + 1
|
103
|
+
end
|
104
|
+
|
105
|
+
# rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
|
106
|
+
def wait_for_user_input
|
107
|
+
char = read_char
|
108
|
+
case @state
|
109
|
+
when :root
|
110
|
+
case char
|
111
|
+
when ESC ; @state = :esc
|
112
|
+
when 'k' ; up
|
113
|
+
when 'j' ; down
|
114
|
+
when ('1'..@options.size.to_s) ; select_n(char.to_i)
|
115
|
+
when 'y', 'n' ; select_bool(char)
|
116
|
+
when " ", "\r", "\n" ; @answer = @active # <enter>
|
117
|
+
when "\u0003" ; raise Interrupt # Ctrl-c
|
118
|
+
end
|
119
|
+
when :esc
|
120
|
+
case char
|
121
|
+
when '[' ; @state = :esc_bracket
|
122
|
+
else ; raise Interrupt # unhandled escape sequence.
|
123
|
+
end
|
124
|
+
when :esc_bracket
|
125
|
+
@state = :root
|
126
|
+
case char
|
127
|
+
when 'A' ; up
|
128
|
+
when 'B' ; down
|
129
|
+
else ; raise Interrupt # unhandled escape sequence.
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
# rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
|
134
|
+
|
135
|
+
def read_char
|
136
|
+
raw_tty! { $stdin.getc.chr }
|
137
|
+
rescue IOError
|
138
|
+
"\e"
|
139
|
+
end
|
140
|
+
|
141
|
+
def raw_tty!
|
142
|
+
if ENV['TEST'] || !$stdin.tty?
|
143
|
+
yield
|
144
|
+
else
|
145
|
+
$stdin.raw { yield }
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def render_options
|
150
|
+
max_num_length = (@options.size + 1).to_s.length
|
151
|
+
@options.each_with_index do |choice, index|
|
152
|
+
num = index + 1
|
153
|
+
padding = ' ' * (max_num_length - num.to_s.length)
|
154
|
+
message = " #{num}.#{padding}"
|
155
|
+
message += choice.split("\n").map { |l| " {{bold:#{l}}}" }.join("\n")
|
156
|
+
|
157
|
+
if num == @active
|
158
|
+
message = message.split("\n").map.with_index do |l, idx|
|
159
|
+
idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}"
|
160
|
+
end.join("\n")
|
161
|
+
end
|
162
|
+
|
163
|
+
CLI::UI.with_frame_color(:blue) do
|
164
|
+
puts CLI::UI.fmt(message)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|