cli-ui 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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