dev-ui 0.1.0 → 0.1.1

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.
@@ -3,25 +3,33 @@ require 'dev/ui'
3
3
  module Dev
4
4
  module UI
5
5
  class Progress
6
- FILLED_BAR = Dev::UI::Glyph.new("◾", 0x2588, Color::CYAN)
7
- UNFILLED_BAR = Dev::UI::Glyph.new("", 0x2588, Color::WHITE)
6
+ # A Cyan filled block
7
+ FILLED_BAR = "\e[46m"
8
+ # A bright white block
9
+ UNFILLED_BAR = "\e[1;47m"
8
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
+ #
9
17
  # Set the percent to X
10
- # Dev::UI::Progress.progress do |bar|
11
- # bar.tick(set_percent: percent)
12
- # end
18
+ # Dev::UI::Progress.progress do |bar|
19
+ # bar.tick(set_percent: percent)
20
+ # end
13
21
  #
14
- # Increase the percent by 1
15
- # Dev::UI::Progress.progress do |bar|
16
- # bar.tick
17
- # end
22
+ # Increase the percent by 1 percent
23
+ # Dev::UI::Progress.progress do |bar|
24
+ # bar.tick
25
+ # end
18
26
  #
19
27
  # Increase the percent by X
20
- # Dev::UI::Progress.progress do |bar|
21
- # bar.tick(percent: 5)
22
- # end
23
- def self.progress
24
- bar = Progress.new
28
+ # Dev::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)
25
33
  print Dev::UI::ANSI.hide_cursor
26
34
  yield(bar)
27
35
  ensure
@@ -32,11 +40,26 @@ module Dev
32
40
  end
33
41
  end
34
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
+ #
35
50
  def initialize(width: Terminal.width)
36
51
  @percent_done = 0
37
52
  @max_width = width
38
53
  end
39
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
+ #
40
63
  def tick(percent: 0.01, set_percent: nil)
41
64
  raise ArgumentError, 'percent and set_percent cannot both be specified' if percent != 0.01 && set_percent
42
65
  @percent_done += percent
@@ -48,6 +71,8 @@ module Dev
48
71
  print Dev::UI::ANSI.end_of_line + "\n"
49
72
  end
50
73
 
74
+ # Format the progress bar to be printed to terminal
75
+ #
51
76
  def to_s
52
77
  suffix = " #{(@percent_done * 100).round(2)}%"
53
78
  workable_width = @max_width - Frame.prefix_width - suffix.size
@@ -55,9 +80,9 @@ module Dev
55
80
  unfilled = workable_width - filled
56
81
 
57
82
  Dev::UI.resolve_text [
58
- (FILLED_BAR.to_s * filled),
59
- (UNFILLED_BAR.to_s * unfilled),
60
- suffix
83
+ FILLED_BAR + ' ' * filled,
84
+ UNFILLED_BAR + ' ' * unfilled,
85
+ Dev::UI::Color::RESET.code + suffix
61
86
  ].join
62
87
  end
63
88
  end
data/lib/dev/ui/prompt.rb CHANGED
@@ -5,6 +5,50 @@ module Dev
5
5
  module UI
6
6
  module Prompt
7
7
  class << self
8
+ # Ask a user a question with either free form answer or a set of answers
9
+ # Do not use this method for yes/no questions. Use +confirm+
10
+ # Can use arrows, y/n, numbers, and vim bindings to control
11
+ #
12
+ # * Handles free form answers (options are nil)
13
+ # * Handles default answers for free form text
14
+ # * Handles file auto completion for file input
15
+ # * Handles interactively choosing answers using +InteractivePrompt+
16
+ #
17
+ # https://user-images.githubusercontent.com/3074765/33799822-47f23302-dd01-11e7-82f3-9072a5a5f611.png
18
+ #
19
+ # ==== Attributes
20
+ #
21
+ # * +question+ - (required) The question to ask the user
22
+ #
23
+ # ==== Options
24
+ #
25
+ # * +:options+ - Options to ask the user. Will use +InteractivePrompt+ to do so
26
+ # * +:default+ - The default answer to the question (e.g. they just press enter and don't input anything)
27
+ # * +:is_file+ - Tells the input to use file auto-completion (tab completion)
28
+ # * +:allow_empty+ - Allows the answer to be empty
29
+ #
30
+ # Note:
31
+ # * +:options+ conflicts with +:default+ and +:is_file+, you cannot set options with either of these keywords
32
+ # * +:default+ conflicts with +:allow_empty:, you cannot set these together
33
+ #
34
+ # ==== Example Usage:
35
+ #
36
+ # Free form question
37
+ # Dev::UI::Prompt.ask('What color is the sky?')
38
+ #
39
+ # Free form question with a file answer
40
+ # Dev::UI::Prompt.ask('Where is your Gemfile located?', is_file: true)
41
+ #
42
+ # Free form question with a default answer
43
+ # Dev::UI::Prompt.ask('What color is the sky?', default: 'blue')
44
+ #
45
+ # Free form question when the answer can be empty
46
+ # Dev::UI::Prompt.ask('What is your opinion on this question?', allow_empty: true)
47
+ #
48
+ # Question with answers
49
+ # Dev::UI::Prompt.ask('What kind of project is this?', options: %w(rails go ruby python))
50
+ #
51
+ #
8
52
  def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true)
9
53
  if (default && !allow_empty) || (options && (default || is_file))
10
54
  raise(ArgumentError, 'conflicting arguments')
@@ -34,6 +78,14 @@ module Dev
34
78
  end
35
79
  end
36
80
 
81
+ # Asks the user a yes/no question.
82
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control
83
+ #
84
+ # ==== Example Usage:
85
+ #
86
+ # Free form question
87
+ # Dev::UI::Prompt.confirm('Is the sky blue?')
88
+ #
37
89
  def confirm(question)
38
90
  puts_question("#{question} {{yellow:(choose with ↑ ↓ ⏎)}}")
39
91
  InteractivePrompt.call(%w(yes no)) == 'yes'
@@ -76,7 +128,8 @@ module Dev
76
128
  prompt = prefix + Dev::UI.fmt('{{blue:> }}{{yellow:')
77
129
 
78
130
  begin
79
- Readline.readline(prompt, true).chomp
131
+ line = Readline.readline(prompt, true)
132
+ line.to_s.chomp
80
133
  rescue Interrupt
81
134
  Dev::UI.raw { STDERR.puts('^C' + Dev::UI::Color::RESET.code) }
82
135
  raise
@@ -3,7 +3,11 @@ require 'dev/ui'
3
3
  module Dev
4
4
  module UI
5
5
  module Spinner
6
+ autoload :Async, 'dev/ui/spinner/async'
7
+ autoload :SpinGroup, 'dev/ui/spinner/spin_group'
8
+
6
9
  PERIOD = 0.1 # seconds
10
+ TASK_FAILED = :task_failed
7
11
 
8
12
  begin
9
13
  runes = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
@@ -12,157 +16,33 @@ module Dev
12
16
  GLYPHS = colors.zip(runes).map(&:join)
13
17
  end
14
18
 
15
- def self.spin(title, &block)
16
- sg = SpinGroup.new
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
+ # Dev::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)
17
43
  sg.add(title, &block)
18
44
  sg.wait
19
45
  end
20
-
21
- class SpinGroup
22
- def initialize
23
- @m = Mutex.new
24
- @consumed_lines = 0
25
- @tasks = []
26
- end
27
-
28
- class Task
29
- attr_reader :title, :exception, :success, :stdout, :stderr
30
-
31
- def initialize(title, &block)
32
- @title = title
33
- @thread = Thread.new do
34
- cap = Dev::UI::StdoutRouter::Capture.new(with_frame_inset: false, &block)
35
- begin
36
- cap.run
37
- ensure
38
- @stdout = cap.stdout
39
- @stderr = cap.stderr
40
- end
41
- end
42
-
43
- @done = false
44
- @exception = nil
45
- @success = false
46
- end
47
-
48
- def check
49
- return true if @done
50
- return false if @thread.alive?
51
-
52
- @done = true
53
- begin
54
- status = @thread.join.status
55
- @success = (status == false)
56
- rescue => exc
57
- @exception = exc
58
- @success = false
59
- end
60
-
61
- @done
62
- end
63
-
64
- def render(index, force = true)
65
- return full_render(index) if force
66
- partial_render(index)
67
- end
68
-
69
- private
70
-
71
- def full_render(index)
72
- inset + glyph(index) + Dev::UI::Color::RESET.code + ' ' + Dev::UI.resolve_text(title)
73
- end
74
-
75
- def partial_render(index)
76
- Dev::UI::ANSI.cursor_forward(inset_width) + glyph(index) + Dev::UI::Color::RESET.code
77
- end
78
-
79
- def glyph(index)
80
- if @done
81
- @success ? Dev::UI::Glyph::CHECK.to_s : Dev::UI::Glyph::X.to_s
82
- else
83
- GLYPHS[index]
84
- end
85
- end
86
-
87
- def inset
88
- @inset ||= Dev::UI::Frame.prefix
89
- end
90
-
91
- def inset_width
92
- @inset_width ||= Dev::UI::ANSI.printing_width(inset)
93
- end
94
- end
95
-
96
- def add(title, &block)
97
- @m.synchronize do
98
- @tasks << Task.new(title, &block)
99
- end
100
- end
101
-
102
- def wait
103
- idx = 0
104
-
105
- loop do
106
- all_done = true
107
-
108
- @m.synchronize do
109
- Dev::UI.raw do
110
- @tasks.each.with_index do |task, int_index|
111
- nat_index = int_index + 1
112
- task_done = task.check
113
- all_done = false unless task_done
114
-
115
- if nat_index > @consumed_lines
116
- print(task.render(idx, true) + "\n")
117
- @consumed_lines += 1
118
- else
119
- offset = @consumed_lines - int_index
120
- move_to = Dev::UI::ANSI.cursor_up(offset) + "\r"
121
- move_from = "\r" + Dev::UI::ANSI.cursor_down(offset)
122
-
123
- print(move_to + task.render(idx, idx.zero?) + move_from)
124
- end
125
- end
126
- end
127
- end
128
-
129
- break if all_done
130
-
131
- idx = (idx + 1) % GLYPHS.size
132
- sleep(PERIOD)
133
- end
134
-
135
- debrief
136
- end
137
-
138
- def debrief
139
- @m.synchronize do
140
- @tasks.each do |task|
141
- next if task.success
142
-
143
- e = task.exception
144
- out = task.stdout
145
- err = task.stderr
146
-
147
- Dev::UI::Frame.open('Task Failed: ' + task.title, color: :red) do
148
- if e
149
- puts"#{e.class}: #{e.message}"
150
- puts "\tfrom #{e.backtrace.join("\n\tfrom ")}"
151
- end
152
-
153
- Dev::UI::Frame.divider('STDOUT')
154
- out = "(empty)" if out.nil? || out.strip.empty?
155
- puts out
156
-
157
- Dev::UI::Frame.divider('STDERR')
158
- err = "(empty)" if err.nil? || err.strip.empty?
159
- puts err
160
- end
161
- end
162
- @tasks.all?(&:success)
163
- end
164
- end
165
- end
166
46
  end
167
47
  end
168
48
  end
@@ -0,0 +1,40 @@
1
+ module Dev
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
+ # Dev::UI::Spinner::Async.new('Title')
21
+ #
22
+ def initialize(title)
23
+ require 'thread'
24
+ sg = Dev::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 Dev
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 = Dev::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 = Dev::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) + Dev::UI::Color::RESET.code + ' ' + Dev::UI.resolve_text(title) + "\e[K"
107
+ end
108
+
109
+ def partial_render(index)
110
+ Dev::UI::ANSI.cursor_forward(inset_width) + glyph(index) + Dev::UI::Color::RESET.code
111
+ end
112
+
113
+ def glyph(index)
114
+ if @done
115
+ @success ? Dev::UI::Glyph::CHECK.to_s : Dev::UI::Glyph::X.to_s
116
+ else
117
+ GLYPHS[index]
118
+ end
119
+ end
120
+
121
+ def inset
122
+ @inset ||= Dev::UI::Frame.prefix
123
+ end
124
+
125
+ def inset_width
126
+ @inset_width ||= Dev::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 = Dev::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 = Dev::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
+ Dev::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 = Dev::UI::ANSI.cursor_up(offset) + "\r"
174
+ move_from = "\r" + Dev::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
+ Dev::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
+ Dev::UI::Frame.divider('STDOUT')
209
+ out = "(empty)" if out.nil? || out.strip.empty?
210
+ puts out
211
+
212
+ Dev::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