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.
@@ -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