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
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'cli/ui'
|
2
|
+
|
3
|
+
module CLI
|
4
|
+
module UI
|
5
|
+
module Spinner
|
6
|
+
autoload :Async, 'cli/ui/spinner/async'
|
7
|
+
autoload :SpinGroup, 'cli/ui/spinner/spin_group'
|
8
|
+
|
9
|
+
PERIOD = 0.1 # seconds
|
10
|
+
TASK_FAILED = :task_failed
|
11
|
+
|
12
|
+
begin
|
13
|
+
runes = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
14
|
+
colors = [CLI::UI::Color::CYAN.code] * 5 + [CLI::UI::Color::MAGENTA.code] * 5
|
15
|
+
raise unless runes.size == colors.size
|
16
|
+
GLYPHS = colors.zip(runes).map(&:join)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Adds a single spinner
|
20
|
+
# Uses an interactive session to allow the user to pick an answer
|
21
|
+
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
|
22
|
+
#
|
23
|
+
# https://user-images.githubusercontent.com/3074765/33798295-d94fd822-dce3-11e7-819b-43e5502d490e.gif
|
24
|
+
#
|
25
|
+
# ==== Attributes
|
26
|
+
#
|
27
|
+
# * +title+ - Title of the spinner to use
|
28
|
+
#
|
29
|
+
# ==== Options
|
30
|
+
#
|
31
|
+
# * +:auto_debrief+ - Automatically debrief exceptions? Default to true
|
32
|
+
#
|
33
|
+
# ==== Block
|
34
|
+
#
|
35
|
+
# * *spinner+ - Instance of the spinner. Can call +update_title+ to update the user of changes
|
36
|
+
#
|
37
|
+
# ==== Example Usage:
|
38
|
+
#
|
39
|
+
# CLI::UI::Spinner.spin('Title') { sleep 1.0 }
|
40
|
+
#
|
41
|
+
def self.spin(title, auto_debrief: true, &block)
|
42
|
+
sg = SpinGroup.new(auto_debrief: auto_debrief)
|
43
|
+
sg.add(title, &block)
|
44
|
+
sg.wait
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module CLI
|
2
|
+
module UI
|
3
|
+
module Spinner
|
4
|
+
class Async
|
5
|
+
# Convenience method for +initialize+
|
6
|
+
#
|
7
|
+
def self.start(title)
|
8
|
+
new(title)
|
9
|
+
end
|
10
|
+
|
11
|
+
# Initializes a new asynchronous spinner with no specific end.
|
12
|
+
# Must call +.stop+ to end the spinner
|
13
|
+
#
|
14
|
+
# ==== Attributes
|
15
|
+
#
|
16
|
+
# * +title+ - Title of the spinner to use
|
17
|
+
#
|
18
|
+
# ==== Example Usage:
|
19
|
+
#
|
20
|
+
# CLI::UI::Spinner::Async.new('Title')
|
21
|
+
#
|
22
|
+
def initialize(title)
|
23
|
+
require 'thread'
|
24
|
+
sg = CLI::UI::Spinner::SpinGroup.new
|
25
|
+
@m = Mutex.new
|
26
|
+
@cv = ConditionVariable.new
|
27
|
+
sg.add(title) { @m.synchronize { @cv.wait(@m) } }
|
28
|
+
@t = Thread.new { sg.wait }
|
29
|
+
end
|
30
|
+
|
31
|
+
# Stops an asynchronous spinner
|
32
|
+
#
|
33
|
+
def stop
|
34
|
+
@m.synchronize { @cv.signal }
|
35
|
+
@t.value
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,223 @@
|
|
1
|
+
module CLI
|
2
|
+
module UI
|
3
|
+
module Spinner
|
4
|
+
class SpinGroup
|
5
|
+
# Initializes a new spin group
|
6
|
+
# This lets you add +Task+ objects to the group to multi-thread work
|
7
|
+
#
|
8
|
+
# ==== Options
|
9
|
+
#
|
10
|
+
# * +:auto_debrief+ - Automatically debrief exceptions? Default to true
|
11
|
+
#
|
12
|
+
# ==== Example Usage
|
13
|
+
#
|
14
|
+
# spin_group = CLI::UI::SpinGroup.new
|
15
|
+
# spin_group.add('Title') { |spinner| sleep 3.0 }
|
16
|
+
# spin_group.add('Title 2') { |spinner| sleep 3.0; spinner.update_title('New Title'); sleep 3.0 }
|
17
|
+
# spin_group.wait
|
18
|
+
#
|
19
|
+
# Output:
|
20
|
+
#
|
21
|
+
# https://user-images.githubusercontent.com/3074765/33798558-c452fa26-dce8-11e7-9e90-b4b34df21a46.gif
|
22
|
+
#
|
23
|
+
def initialize(auto_debrief: true)
|
24
|
+
@m = Mutex.new
|
25
|
+
@consumed_lines = 0
|
26
|
+
@tasks = []
|
27
|
+
@auto_debrief = auto_debrief
|
28
|
+
end
|
29
|
+
|
30
|
+
class Task
|
31
|
+
attr_reader :title, :exception, :success, :stdout, :stderr
|
32
|
+
|
33
|
+
# Initializes a new Task
|
34
|
+
# This is managed entirely internally by +SpinGroup+
|
35
|
+
#
|
36
|
+
# ==== Attributes
|
37
|
+
#
|
38
|
+
# * +title+ - Title of the task
|
39
|
+
# * +block+ - Block for the task, will be provided with an instance of the spinner
|
40
|
+
#
|
41
|
+
def initialize(title, &block)
|
42
|
+
@title = title
|
43
|
+
@thread = Thread.new do
|
44
|
+
cap = CLI::UI::StdoutRouter::Capture.new(self, with_frame_inset: false, &block)
|
45
|
+
begin
|
46
|
+
cap.run
|
47
|
+
ensure
|
48
|
+
@stdout = cap.stdout
|
49
|
+
@stderr = cap.stderr
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
@force_full_render = false
|
54
|
+
@done = false
|
55
|
+
@exception = nil
|
56
|
+
@success = false
|
57
|
+
end
|
58
|
+
|
59
|
+
# Checks if a task is finished
|
60
|
+
#
|
61
|
+
def check
|
62
|
+
return true if @done
|
63
|
+
return false if @thread.alive?
|
64
|
+
|
65
|
+
@done = true
|
66
|
+
begin
|
67
|
+
status = @thread.join.status
|
68
|
+
@success = (status == false)
|
69
|
+
@success = false if @thread.value == TASK_FAILED
|
70
|
+
rescue => exc
|
71
|
+
@exception = exc
|
72
|
+
@success = false
|
73
|
+
end
|
74
|
+
|
75
|
+
@done
|
76
|
+
end
|
77
|
+
|
78
|
+
# Re-renders the task if required
|
79
|
+
#
|
80
|
+
# ==== Attributes
|
81
|
+
#
|
82
|
+
# * +index+ - index of the task
|
83
|
+
# * +force+ - force rerender of the task
|
84
|
+
#
|
85
|
+
def render(index, force = true)
|
86
|
+
return full_render(index) if force || @force_full_render
|
87
|
+
partial_render(index)
|
88
|
+
ensure
|
89
|
+
@force_full_render = false
|
90
|
+
end
|
91
|
+
|
92
|
+
# Update the spinner title
|
93
|
+
#
|
94
|
+
# ==== Attributes
|
95
|
+
#
|
96
|
+
# * +title+ - title to change the spinner to
|
97
|
+
#
|
98
|
+
def update_title(new_title)
|
99
|
+
@title = new_title
|
100
|
+
@force_full_render = true
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def full_render(index)
|
106
|
+
inset + glyph(index) + CLI::UI::Color::RESET.code + ' ' + CLI::UI.resolve_text(title) + "\e[K"
|
107
|
+
end
|
108
|
+
|
109
|
+
def partial_render(index)
|
110
|
+
CLI::UI::ANSI.cursor_forward(inset_width) + glyph(index) + CLI::UI::Color::RESET.code
|
111
|
+
end
|
112
|
+
|
113
|
+
def glyph(index)
|
114
|
+
if @done
|
115
|
+
@success ? CLI::UI::Glyph::CHECK.to_s : CLI::UI::Glyph::X.to_s
|
116
|
+
else
|
117
|
+
GLYPHS[index]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def inset
|
122
|
+
@inset ||= CLI::UI::Frame.prefix
|
123
|
+
end
|
124
|
+
|
125
|
+
def inset_width
|
126
|
+
@inset_width ||= CLI::UI::ANSI.printing_width(inset)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Add a new task
|
131
|
+
#
|
132
|
+
# ==== Attributes
|
133
|
+
#
|
134
|
+
# * +title+ - Title of the task
|
135
|
+
# * +block+ - Block for the task, will be provided with an instance of the spinner
|
136
|
+
#
|
137
|
+
# ==== Example Usage:
|
138
|
+
# spin_group = CLI::UI::SpinGroup.new
|
139
|
+
# spin_group.add('Title') { |spinner| sleep 1.0 }
|
140
|
+
# spin_group.wait
|
141
|
+
#
|
142
|
+
def add(title, &block)
|
143
|
+
@m.synchronize do
|
144
|
+
@tasks << Task.new(title, &block)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# Tells the group you're done adding tasks and to wait for all of them to finish
|
149
|
+
#
|
150
|
+
# ==== Example Usage:
|
151
|
+
# spin_group = CLI::UI::SpinGroup.new
|
152
|
+
# spin_group.add('Title') { |spinner| sleep 1.0 }
|
153
|
+
# spin_group.wait
|
154
|
+
#
|
155
|
+
def wait
|
156
|
+
idx = 0
|
157
|
+
|
158
|
+
loop do
|
159
|
+
all_done = true
|
160
|
+
|
161
|
+
@m.synchronize do
|
162
|
+
CLI::UI.raw do
|
163
|
+
@tasks.each.with_index do |task, int_index|
|
164
|
+
nat_index = int_index + 1
|
165
|
+
task_done = task.check
|
166
|
+
all_done = false unless task_done
|
167
|
+
|
168
|
+
if nat_index > @consumed_lines
|
169
|
+
print(task.render(idx, true) + "\n")
|
170
|
+
@consumed_lines += 1
|
171
|
+
else
|
172
|
+
offset = @consumed_lines - int_index
|
173
|
+
move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
|
174
|
+
move_from = "\r" + CLI::UI::ANSI.cursor_down(offset)
|
175
|
+
|
176
|
+
print(move_to + task.render(idx, idx.zero?) + move_from)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
break if all_done
|
183
|
+
|
184
|
+
idx = (idx + 1) % GLYPHS.size
|
185
|
+
sleep(PERIOD)
|
186
|
+
end
|
187
|
+
|
188
|
+
debrief if @auto_debrief
|
189
|
+
end
|
190
|
+
|
191
|
+
# Debriefs failed tasks is +auto_debrief+ is true
|
192
|
+
#
|
193
|
+
def debrief
|
194
|
+
@m.synchronize do
|
195
|
+
@tasks.each do |task|
|
196
|
+
next if task.success
|
197
|
+
|
198
|
+
e = task.exception
|
199
|
+
out = task.stdout
|
200
|
+
err = task.stderr
|
201
|
+
|
202
|
+
CLI::UI::Frame.open('Task Failed: ' + task.title, color: :red) do
|
203
|
+
if e
|
204
|
+
puts "#{e.class}: #{e.message}"
|
205
|
+
puts "\tfrom #{e.backtrace.join("\n\tfrom ")}"
|
206
|
+
end
|
207
|
+
|
208
|
+
CLI::UI::Frame.divider('STDOUT')
|
209
|
+
out = "(empty)" if out.nil? || out.strip.empty?
|
210
|
+
puts out
|
211
|
+
|
212
|
+
CLI::UI::Frame.divider('STDERR')
|
213
|
+
err = "(empty)" if err.nil? || err.strip.empty?
|
214
|
+
puts err
|
215
|
+
end
|
216
|
+
end
|
217
|
+
@tasks.all?(&:success)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
@@ -0,0 +1,188 @@
|
|
1
|
+
require 'cli/ui'
|
2
|
+
require 'stringio'
|
3
|
+
|
4
|
+
module CLI
|
5
|
+
module UI
|
6
|
+
module StdoutRouter
|
7
|
+
class << self
|
8
|
+
attr_accessor :duplicate_output_to
|
9
|
+
end
|
10
|
+
|
11
|
+
class Writer
|
12
|
+
def initialize(stream, name)
|
13
|
+
@stream = stream
|
14
|
+
@name = name
|
15
|
+
end
|
16
|
+
|
17
|
+
def write(*args)
|
18
|
+
if auto_frame_inset?
|
19
|
+
str = args[0].dup # unfreeze
|
20
|
+
str = str.force_encoding(Encoding::UTF_8)
|
21
|
+
str = apply_line_prefix(str, CLI::UI::Frame.prefix)
|
22
|
+
args[0] = str
|
23
|
+
else
|
24
|
+
@pending_newline = false
|
25
|
+
end
|
26
|
+
|
27
|
+
hook = Thread.current[:cliui_output_hook]
|
28
|
+
# hook return of false suppresses output.
|
29
|
+
if !hook || hook.call(args.first, @name) != false
|
30
|
+
args.first
|
31
|
+
@stream.write_without_cli_ui(*args)
|
32
|
+
if dup = StdoutRouter.duplicate_output_to
|
33
|
+
dup.write(*args)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def auto_frame_inset?
|
41
|
+
!Thread.current[:no_cliui_frame_inset]
|
42
|
+
end
|
43
|
+
|
44
|
+
def apply_line_prefix(str, prefix)
|
45
|
+
return '' if str.empty?
|
46
|
+
prefixed = String.new
|
47
|
+
str.force_encoding(Encoding::UTF_8).lines.each do |line|
|
48
|
+
if @pending_newline
|
49
|
+
prefixed << line
|
50
|
+
@pending_newline = false
|
51
|
+
else
|
52
|
+
prefixed << prefix << line
|
53
|
+
end
|
54
|
+
end
|
55
|
+
@pending_newline = !str.end_with?("\n")
|
56
|
+
prefixed
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
class Capture
|
61
|
+
@m = Mutex.new
|
62
|
+
@active_captures = 0
|
63
|
+
@saved_stdin = nil
|
64
|
+
|
65
|
+
def self.with_stdin_masked
|
66
|
+
@m.synchronize do
|
67
|
+
if @active_captures.zero?
|
68
|
+
@saved_stdin = $stdin
|
69
|
+
$stdin, w = IO.pipe
|
70
|
+
$stdin.close
|
71
|
+
w.close
|
72
|
+
end
|
73
|
+
@active_captures += 1
|
74
|
+
end
|
75
|
+
|
76
|
+
yield
|
77
|
+
ensure
|
78
|
+
@m.synchronize do
|
79
|
+
@active_captures -= 1
|
80
|
+
if @active_captures.zero?
|
81
|
+
$stdin = @saved_stdin
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def initialize(*block_args, with_frame_inset: true, &block)
|
87
|
+
@with_frame_inset = with_frame_inset
|
88
|
+
@block_args = block_args
|
89
|
+
@block = block
|
90
|
+
end
|
91
|
+
|
92
|
+
attr_reader :stdout, :stderr
|
93
|
+
|
94
|
+
def run
|
95
|
+
require 'stringio'
|
96
|
+
|
97
|
+
StdoutRouter.assert_enabled!
|
98
|
+
|
99
|
+
out = StringIO.new
|
100
|
+
err = StringIO.new
|
101
|
+
|
102
|
+
prev_frame_inset = Thread.current[:no_cliui_frame_inset]
|
103
|
+
prev_hook = Thread.current[:cliui_output_hook]
|
104
|
+
|
105
|
+
self.class.with_stdin_masked do
|
106
|
+
Thread.current[:no_cliui_frame_inset] = !@with_frame_inset
|
107
|
+
Thread.current[:cliui_output_hook] = ->(data, stream) do
|
108
|
+
case stream
|
109
|
+
when :stdout then out.write(data)
|
110
|
+
when :stderr then err.write(data)
|
111
|
+
else raise
|
112
|
+
end
|
113
|
+
false # suppress writing to terminal
|
114
|
+
end
|
115
|
+
|
116
|
+
begin
|
117
|
+
@block.call(*@block_args)
|
118
|
+
ensure
|
119
|
+
@stdout = out.string
|
120
|
+
@stderr = err.string
|
121
|
+
end
|
122
|
+
end
|
123
|
+
ensure
|
124
|
+
Thread.current[:cliui_output_hook] = prev_hook
|
125
|
+
Thread.current[:no_cliui_frame_inset] = prev_frame_inset
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
class << self
|
130
|
+
WRITE_WITHOUT_CLI_UI = :write_without_cli_ui
|
131
|
+
|
132
|
+
NotEnabled = Class.new(StandardError)
|
133
|
+
|
134
|
+
def assert_enabled!
|
135
|
+
raise NotEnabled unless enabled?
|
136
|
+
end
|
137
|
+
|
138
|
+
def with_enabled
|
139
|
+
enable
|
140
|
+
yield
|
141
|
+
ensure
|
142
|
+
disable
|
143
|
+
end
|
144
|
+
|
145
|
+
# TODO: remove this
|
146
|
+
def ensure_activated
|
147
|
+
enable unless enabled?
|
148
|
+
end
|
149
|
+
|
150
|
+
def enable
|
151
|
+
return false if enabled?($stdout) || enabled?($stderr)
|
152
|
+
activate($stdout, :stdout)
|
153
|
+
activate($stderr, :stderr)
|
154
|
+
true
|
155
|
+
end
|
156
|
+
|
157
|
+
def enabled?(stream = $stdout)
|
158
|
+
stream.respond_to?(WRITE_WITHOUT_CLI_UI)
|
159
|
+
end
|
160
|
+
|
161
|
+
def disable
|
162
|
+
return false unless enabled?($stdout) && enabled?($stderr)
|
163
|
+
deactivate($stdout)
|
164
|
+
deactivate($stderr)
|
165
|
+
true
|
166
|
+
end
|
167
|
+
|
168
|
+
private
|
169
|
+
|
170
|
+
def deactivate(stream)
|
171
|
+
sc = stream.singleton_class
|
172
|
+
sc.send(:remove_method, :write)
|
173
|
+
sc.send(:alias_method, :write, WRITE_WITHOUT_CLI_UI)
|
174
|
+
end
|
175
|
+
|
176
|
+
def activate(stream, streamname)
|
177
|
+
writer = StdoutRouter::Writer.new(stream, streamname)
|
178
|
+
|
179
|
+
raise if stream.respond_to?(WRITE_WITHOUT_CLI_UI)
|
180
|
+
stream.singleton_class.send(:alias_method, WRITE_WITHOUT_CLI_UI, :write)
|
181
|
+
stream.define_singleton_method(:write) do |*args|
|
182
|
+
writer.write(*args)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|