cli-ui 2.0.0 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8dec5198cdbbb3a6060fc448a9a03b5bc6eca461b94a62ead0f128664bb0ee2
4
- data.tar.gz: bed02e7e27771141c08859c11d7e01f690438bb46d4022f5225d8cb42e39efe4
3
+ metadata.gz: 8f063229317bef21e8a24f2190dd907c1a1080eefad787871dc8f07b95a3c31b
4
+ data.tar.gz: 6379c4023ca55081b2d5260ce79ed9615c304c7ab099b9318883a04ef4d11e1e
5
5
  SHA512:
6
- metadata.gz: 6944f7c1b8492e69bd078b2ca0d9901d26694e4542976cb07729efff2cb59438cbc2ec61692b75ccf178ffcec896a297264be5734520695f9bc11779f2698ead
7
- data.tar.gz: c9795b1749d3db7cb57bccca815495ed9b2e8ee02092fe95b46bf451f2993e65c33f8b48e9f6520b2984b7278db64cb9e92bb1fe58a761691a9b313dab6ef464
6
+ metadata.gz: 07f5f6b944ceeadd42359bc5c3edde469fb034641ad0ea274306097ec2471af1d8b6fb1fbc85f5d30c43ddb1472b6af1ee1688a921deea6404cbf642239d4ffe
7
+ data.tar.gz: 25c645e1b2c6078e3ace849ef02ff85b727e88df4dedfa4baf200a56760369cf440ca435fe8ca18e3e24f301dfa80e772e48c6ccdf12a5dd9b6e4c2d5b4ae353
data/README.md CHANGED
@@ -52,6 +52,11 @@ For large numbers of options, using `e`, `:`, or `G` will toggle "line select" m
52
52
  CLI::UI.ask('What language/framework do you use?', options: %w(rails go ruby python))
53
53
  ```
54
54
 
55
+ To set the color of instruction text:
56
+ ```ruby
57
+ CLI::UI::Prompt.instructions_color = CLI::UI::Color::GRAY
58
+ ```
59
+
55
60
  Can also assign callbacks to each option
56
61
 
57
62
  ```ruby
@@ -175,6 +180,12 @@ end
175
180
 
176
181
  ---
177
182
 
183
+ ## Sorbet
184
+
185
+ We make use of [Sorbet](https://sorbet.org/) in cli-ui. We provide stubs for Sorbet so that you can use this gem even
186
+ if you aren't using Sorbet. We activate these stubs if `T` is undefined when the gem is loaded. For this reason, if you
187
+ would like to use this gem and your project _does_ use Sorbet, ensure you load Sorbet _before_ loading cli-ui.
188
+
178
189
  ## Example Usage
179
190
 
180
191
  The following code makes use of nested-framing, multi-threaded spinners, formatted text, and more.
data/lib/cli/ui/ansi.rb CHANGED
@@ -133,6 +133,21 @@ module CLI
133
133
  cmd
134
134
  end
135
135
 
136
+ sig { returns(String) }
137
+ def enter_alternate_screen
138
+ control('?1049', 'h')
139
+ end
140
+
141
+ sig { returns(String) }
142
+ def exit_alternate_screen
143
+ control('?1049', 'l')
144
+ end
145
+
146
+ sig { returns(Regexp) }
147
+ def match_alternate_screen
148
+ /#{Regexp.escape(control('?1049', ''))}[hl]/
149
+ end
150
+
136
151
  # Show the cursor
137
152
  #
138
153
  sig { returns(String) }
data/lib/cli/ui/color.rb CHANGED
@@ -88,7 +88,7 @@ module CLI
88
88
  def lookup(name)
89
89
  MAP.fetch(name.to_sym)
90
90
  rescue KeyError
91
- raise InvalidColorName, name
91
+ raise InvalidColorName, name.to_sym
92
92
  end
93
93
 
94
94
  # All available colors by name
@@ -116,7 +116,7 @@ module CLI
116
116
  return content unless enable_color
117
117
  return content if stack == prev_fmt
118
118
 
119
- unless stack.empty? && (@nodes.size.zero? || T.must(@nodes.last)[1].empty?)
119
+ unless stack.empty? && (@nodes.empty? || T.must(@nodes.last)[1].empty?)
120
120
  content << apply_format('', stack, sgr_map)
121
121
  end
122
122
  content
@@ -167,7 +167,8 @@ module CLI
167
167
  index = sc.pos - 2 # rewind past '}}'
168
168
  raise(FormatError.new(
169
169
  "invalid widget handle at index #{index}: '#{widget_handle}'",
170
- @text, index
170
+ @text,
171
+ index,
171
172
  ))
172
173
  end
173
174
  elsif (match = sc.scan(SCAN_FUNCNAME))
@@ -4,9 +4,6 @@ module CLI
4
4
  module UI
5
5
  module Frame
6
6
  module FrameStack
7
- COLOR_ENVVAR = 'CLI_FRAME_STACK'
8
- STYLE_ENVVAR = 'CLI_STYLE_STACK'
9
-
10
7
  class StackItem
11
8
  extend T::Sig
12
9
 
@@ -32,12 +29,7 @@ module CLI
32
29
  # Fetch all items off the frame stack
33
30
  sig { returns(T::Array[StackItem]) }
34
31
  def items
35
- colors = ENV.fetch(COLOR_ENVVAR, '').split(':').map(&:to_sym)
36
- styles = ENV.fetch(STYLE_ENVVAR, '').split(':').map(&:to_sym)
37
-
38
- colors.each_with_index.map do |color, i|
39
- StackItem.new(color, styles[i] || Frame.frame_style)
40
- end
32
+ Thread.current[:cliui_frame_stack] ||= []
41
33
  end
42
34
 
43
35
  # Push a new item onto the frame stack.
@@ -71,44 +63,13 @@ module CLI
71
63
  raise ArgumentError, 'Must give one of item or color: and style:'
72
64
  end
73
65
 
74
- item ||= StackItem.new(T.must(color), T.must(style))
75
-
76
- curr = items
77
- curr << item
78
-
79
- serialize(curr)
66
+ items.push(item || StackItem.new(T.must(color), T.must(style)))
80
67
  end
81
68
 
82
69
  # Removes and returns the last stack item off the stack
83
70
  sig { returns(T.nilable(StackItem)) }
84
71
  def pop
85
- curr = items
86
- ret = curr.pop
87
-
88
- serialize(curr)
89
-
90
- ret.nil? ? nil : ret
91
- end
92
-
93
- private
94
-
95
- # Serializes the item stack into two ENV variables.
96
- #
97
- # This is done to preserve backward compatibility with earlier versions of cli/ui.
98
- # This ensures that any code that relied upon previous stack behavior should continue
99
- # to work.
100
- sig { params(items: T::Array[StackItem]).void }
101
- def serialize(items)
102
- colors = []
103
- styles = []
104
-
105
- items.each do |item|
106
- colors << item.color.name
107
- styles << item.frame_style.style_name
108
- end
109
-
110
- ENV[COLOR_ENVVAR] = colors.join(':')
111
- ENV[STYLE_ENVVAR] = styles.join(':')
72
+ items.pop
112
73
  end
113
74
  end
114
75
  end
@@ -112,7 +112,7 @@ module CLI
112
112
  preamble_start = Frame.prefix_width
113
113
  # If prefix_width is non-zero, we need to subtract the width of
114
114
  # the final space, since we're going to write over it.
115
- preamble_start -= 1 unless preamble_start.zero?
115
+ preamble_start -= 1 if preamble_start.nonzero?
116
116
  preamble_end = preamble_start + preamble_width
117
117
 
118
118
  suffix_width = CLI::UI::ANSI.printing_width(suffix)
@@ -126,7 +126,7 @@ module CLI
126
126
 
127
127
  # If prefix_width is non-zero, we need to subtract the width of
128
128
  # the final space, since we're going to write over it.
129
- preamble_start -= 1 unless preamble_start.zero?
129
+ preamble_start -= 1 if preamble_start.nonzero?
130
130
 
131
131
  # Jumping around the line can cause some unwanted flashes
132
132
  o << CLI::UI::ANSI.hide_cursor
data/lib/cli/ui/frame.rb CHANGED
@@ -54,6 +54,8 @@ module CLI
54
54
  # * +:success_text+ - If the block succeeds, what do we output? Defaults to nil
55
55
  # * +:timing+ - How long did the frame content take? Invalid for blockless. Defaults to true for the block form
56
56
  # * +frame_style+ - The frame style to use for this frame
57
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods,
58
+ # or under Sorbet, IO or StringIO. Defaults to $stdout.
57
59
  #
58
60
  # ==== Example
59
61
  #
@@ -82,6 +84,7 @@ module CLI
82
84
  success_text: T.nilable(String),
83
85
  timing: T.any(T::Boolean, Numeric),
84
86
  frame_style: FrameStylable,
87
+ to: IOLike,
85
88
  block: T.nilable(T.proc.returns(T.type_parameter(:T))),
86
89
  ).returns(T.nilable(T.type_parameter(:T)))
87
90
  end
@@ -92,6 +95,7 @@ module CLI
92
95
  success_text: nil,
93
96
  timing: block_given?,
94
97
  frame_style: self.frame_style,
98
+ to: $stdout,
95
99
  &block
96
100
  )
97
101
  frame_style = CLI::UI.resolve_style(frame_style)
@@ -109,8 +113,8 @@ module CLI
109
113
 
110
114
  t_start = Time.now
111
115
  CLI::UI.raw do
112
- print(prefix.chop)
113
- puts frame_style.start(text, color: color)
116
+ to.print(prefix.chop)
117
+ to.puts(frame_style.start(text, color: color))
114
118
  end
115
119
  FrameStack.push(color: color, style: frame_style)
116
120
 
@@ -123,7 +127,7 @@ module CLI
123
127
  rescue
124
128
  closed = true
125
129
  t_diff = elapsed(t_start, timing)
126
- close(failure_text, color: :red, elapsed: t_diff)
130
+ close(failure_text, color: :red, elapsed: t_diff, to: to)
127
131
  raise
128
132
  else
129
133
  success
@@ -131,9 +135,9 @@ module CLI
131
135
  unless closed
132
136
  t_diff = elapsed(t_start, timing)
133
137
  if T.unsafe(success) != false
134
- close(success_text, color: color, elapsed: t_diff)
138
+ close(success_text, color: color, elapsed: t_diff, to: to)
135
139
  else
136
- close(failure_text, color: :red, elapsed: t_diff)
140
+ close(failure_text, color: :red, elapsed: t_diff, to: to)
137
141
  end
138
142
  end
139
143
  end
@@ -150,6 +154,8 @@ module CLI
150
154
  #
151
155
  # * +:color+ - The color of the frame. Defaults to +DEFAULT_FRAME_COLOR+
152
156
  # * +frame_style+ - The frame style to use for this frame
157
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods,
158
+ # or under Sorbet, IO or StringIO. Defaults to $stdout.
153
159
  #
154
160
  # ==== Example
155
161
  #
@@ -164,8 +170,15 @@ module CLI
164
170
  #
165
171
  # MUST be inside an open frame or it raises a +UnnestedFrameException+
166
172
  #
167
- sig { params(text: T.nilable(String), color: T.nilable(Colorable), frame_style: T.nilable(FrameStylable)).void }
168
- def divider(text, color: nil, frame_style: nil)
173
+ sig do
174
+ params(
175
+ text: T.nilable(String),
176
+ color: T.nilable(Colorable),
177
+ frame_style: T.nilable(FrameStylable),
178
+ to: IOLike,
179
+ ).void
180
+ end
181
+ def divider(text, color: nil, frame_style: nil, to: $stdout)
169
182
  fs_item = FrameStack.pop
170
183
  raise UnnestedFrameException, 'No frame nesting to unnest' unless fs_item
171
184
 
@@ -173,8 +186,8 @@ module CLI
173
186
  frame_style = CLI::UI.resolve_style(frame_style || fs_item.frame_style)
174
187
 
175
188
  CLI::UI.raw do
176
- print(prefix.chop)
177
- puts frame_style.divider(text.to_s, color: divider_color)
189
+ to.print(prefix.chop)
190
+ to.puts(frame_style.divider(text.to_s, color: divider_color))
178
191
  end
179
192
 
180
193
  FrameStack.push(fs_item)
@@ -192,6 +205,8 @@ module CLI
192
205
  # * +:color+ - The color of the frame. Defaults to nil
193
206
  # * +:elapsed+ - How long did the frame take? Defaults to nil
194
207
  # * +frame_style+ - The frame style to use for this frame. Defaults to nil
208
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods,
209
+ # or under Sorbet, IO or StringIO. Defaults to $stdout.
195
210
  #
196
211
  # ==== Example
197
212
  #
@@ -210,9 +225,10 @@ module CLI
210
225
  color: T.nilable(Colorable),
211
226
  elapsed: T.nilable(Numeric),
212
227
  frame_style: T.nilable(FrameStylable),
228
+ to: IOLike,
213
229
  ).void
214
230
  end
215
- def close(text, color: nil, elapsed: nil, frame_style: nil)
231
+ def close(text, color: nil, elapsed: nil, frame_style: nil, to: $stdout)
216
232
  fs_item = FrameStack.pop
217
233
  raise UnnestedFrameException, 'No frame nesting to unnest' unless fs_item
218
234
 
@@ -221,8 +237,8 @@ module CLI
221
237
  elapsed_string = elapsed ? "(#{elapsed.round(2)}s)" : nil
222
238
 
223
239
  CLI::UI.raw do
224
- print(prefix.chop)
225
- puts frame_style.close(text.to_s, color: close_color, right_text: elapsed_string)
240
+ to.print(prefix.chop)
241
+ to.puts(frame_style.close(text.to_s, color: close_color, right_text: elapsed_string))
226
242
  end
227
243
  end
228
244
 
@@ -45,7 +45,7 @@ module CLI
45
45
  print(CLI::UI::ANSI.hide_cursor)
46
46
  yield(bar)
47
47
  ensure
48
- puts bar.to_s
48
+ puts(bar)
49
49
  CLI::UI.raw do
50
50
  print(ANSI.show_cursor)
51
51
  end
@@ -83,7 +83,7 @@ module CLI
83
83
  @percent_done = set_percent if set_percent
84
84
  @percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
85
85
 
86
- print(to_s)
86
+ print(self)
87
87
  print(CLI::UI::ANSI.previous_line + "\n")
88
88
  end
89
89
 
@@ -111,21 +111,29 @@ module CLI
111
111
  # so to get the # of lines, you need to join then split
112
112
 
113
113
  # since lines may be longer than the terminal is wide, we need to
114
- # determine how many extra lines would be taken up by them
115
- max_width = (@terminal_width_at_calculation_time -
116
- @options.count.to_s.size - # Width of the displayed number
117
- 5 - # Extra characters added during rendering
118
- (@multiple ? 1 : 0) # Space for the checkbox, if rendered
119
- ).to_f
114
+ # determine how many extra lines would be taken up by them.
115
+ #
116
+ # To accomplish this we split the string by new lines and add the
117
+ # extra characters to the first line.
118
+ # Then we calculate how many lines would be needed to render the string
119
+ # based on the terminal width
120
+ # 3 = space before the number, the . after the number, the space after the .
121
+ # multiple check is for the space for the checkbox, if rendered
122
+ # options.count.to_s.size gets us the max size of the number we will display
123
+ extra_chars = @marker.length + 3 + @options.count.to_s.size + (@multiple ? 1 : 0)
120
124
 
121
125
  @option_lengths = @options.map do |text|
122
- width = 1 if text.empty?
123
- width ||= text
124
- .split("\n")
125
- .reject(&:empty?)
126
- .sum { |l| (CLI::UI.fmt(l, enable_color: false).length / max_width).ceil }
126
+ next 1 if text.empty?
127
127
 
128
- width
128
+ # Find the length of all the lines in this string
129
+ non_empty_line_lengths = text.split("\n").reject(&:empty?).map do |line|
130
+ CLI::UI.fmt(line, enable_color: false).length
131
+ end
132
+ # The first line has the marker and number, so we add that so we can take it into account
133
+ non_empty_line_lengths[0] += extra_chars
134
+ # Finally, we need to calculate how many lines each one will take. We can do that by dividing each one
135
+ # by the width of the terminal, rounding up to the nearest natural number
136
+ non_empty_line_lengths.sum { |length| (length.to_f / @terminal_width_at_calculation_time).ceil }
129
137
  end
130
138
  end
131
139
 
@@ -285,7 +293,7 @@ module CLI
285
293
  # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
286
294
  sig { void }
287
295
  def wait_for_user_input
288
- char = read_char
296
+ char = Prompt.read_char
289
297
  @last_char = char
290
298
 
291
299
  case char
@@ -375,17 +383,6 @@ module CLI
375
383
  @redraw = true
376
384
  end
377
385
 
378
- sig { returns(T.nilable(String)) }
379
- def read_char
380
- if $stdin.tty? && !ENV['TEST']
381
- $stdin.getch # raw mode for tty
382
- else
383
- $stdin.getc # returns nil at end of input
384
- end
385
- rescue Errno::EIO, Errno::EPIPE, IOError
386
- "\e"
387
- end
388
-
389
386
  sig { params(recalculate: T::Boolean).returns(T::Array[[String, T.nilable(Integer)]]) }
390
387
  def presented_options(recalculate: false)
391
388
  return @presented_options unless recalculate
@@ -497,8 +494,10 @@ module CLI
497
494
  message = " #{num}#{num ? "." : " "}#{padding}"
498
495
 
499
496
  format = '%s'
500
- # If multiple, bold only selected. If not multiple, bold everything
501
- format = "{{bold:#{format}}}" if !@multiple || is_chosen
497
+ # If multiple, bold selected. If not multiple, do not bold any options.
498
+ # Bolding options can cause confusion as some users may perceive bold white (default color) as selected
499
+ # rather than the actual selected color.
500
+ format = "{{bold:#{format}}}" if @multiple && is_chosen
502
501
  format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
503
502
  format = " #{format}"
504
503
 
@@ -508,7 +507,7 @@ module CLI
508
507
  if num == @active
509
508
 
510
509
  color = filtering? || selecting? ? 'green' : 'blue'
511
- message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
510
+ message = message.split("\n").map { |l| "{{#{color}:#{@marker} #{l.strip}}}" }.join("\n")
512
511
  end
513
512
 
514
513
  puts CLI::UI.fmt(message)
data/lib/cli/ui/prompt.rb CHANGED
@@ -29,6 +29,22 @@ module CLI
29
29
  class << self
30
30
  extend T::Sig
31
31
 
32
+ sig { returns(Color) }
33
+ def instructions_color
34
+ @instructions_color ||= Color::YELLOW
35
+ end
36
+
37
+ # Set the instructions color.
38
+ #
39
+ # ==== Attributes
40
+ #
41
+ # * +color+ - the color to use for prompt instructions
42
+ #
43
+ sig { params(color: Colorable).void }
44
+ def instructions_color=(color)
45
+ @instructions_color = CLI::UI.resolve_color(color)
46
+ end
47
+
32
48
  # Ask a user a question with either free form answer or a set of answers (multiple choice)
33
49
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control multiple choice selection
34
50
  # Do not use this method for yes/no questions. Use +confirm+
@@ -167,19 +183,21 @@ module CLI
167
183
  def ask_password(question)
168
184
  require 'io/console'
169
185
 
170
- STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
186
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
187
+ $stdout.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
171
188
 
172
- # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
173
- # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
174
- password = STDIN.noecho do
175
- # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
176
- # " 123 \n".chomp => " 123 "
177
- STDIN.gets.to_s.chomp
178
- end
189
+ # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
190
+ # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
191
+ password = $stdin.noecho do
192
+ # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
193
+ # " 123 \n".chomp => " 123 "
194
+ $stdin.gets.to_s.chomp
195
+ end
179
196
 
180
- STDOUT.puts # Complete the line
197
+ $stdout.puts # Complete the line
181
198
 
182
- password
199
+ password
200
+ end
183
201
  end
184
202
 
185
203
  # Asks the user a yes/no question.
@@ -197,6 +215,36 @@ module CLI
197
215
  ask_interactive(question, default ? ['yes', 'no'] : ['no', 'yes'], filter_ui: false) == 'yes'
198
216
  end
199
217
 
218
+ # Present the user with a message and wait for any key to be pressed, returning the pressed key.
219
+ #
220
+ # ==== Example Usage:
221
+ #
222
+ # CLI::UI::Prompt.any_key # Press any key to continue...
223
+ #
224
+ # CLI::UI::Prompt.any_key('Press RETURN to continue...') # Then check if that's what they pressed
225
+ sig { params(prompt: String).returns(T.nilable(String)) }
226
+ def any_key(prompt = 'Press any key to continue...')
227
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
228
+ puts_question(prompt)
229
+ read_char
230
+ end
231
+ end
232
+
233
+ # Wait for any key to be pressed, returning the pressed key.
234
+ sig { returns(T.nilable(String)) }
235
+ def read_char
236
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
237
+ if $stdin.tty? && !ENV['TEST']
238
+ require 'io/console'
239
+ $stdin.getch # raw mode for tty
240
+ else
241
+ $stdin.getc # returns nil at end of input
242
+ end
243
+ end
244
+ rescue Errno::EIO, Errno::EPIPE, IOError
245
+ "\e"
246
+ end
247
+
200
248
  private
201
249
 
202
250
  sig do
@@ -208,23 +256,25 @@ module CLI
208
256
  raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
209
257
  end
210
258
 
211
- if default
212
- puts_question("#{question} (empty = #{default})")
213
- else
214
- puts_question(question)
215
- end
259
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
260
+ if default
261
+ puts_question("#{question} (empty = #{default})")
262
+ else
263
+ puts_question(question)
264
+ end
216
265
 
217
- # Ask a free form question
218
- loop do
219
- line = readline(is_file: is_file)
266
+ # Ask a free form question
267
+ loop do
268
+ line = readline(is_file: is_file)
220
269
 
221
- if line.empty? && default
222
- write_default_over_empty_input(default)
223
- return default
224
- end
270
+ if line.empty? && default
271
+ write_default_over_empty_input(default)
272
+ return default
273
+ end
225
274
 
226
- if !line.empty? || allow_empty
227
- return line
275
+ if !line.empty? || allow_empty
276
+ return line
277
+ end
228
278
  end
229
279
  end
230
280
  end
@@ -259,33 +309,38 @@ module CLI
259
309
  instructions = (multiple ? 'Toggle options. ' : '') + navigate_text
260
310
  instructions += ", filter with 'f'" if filter_ui
261
311
  instructions += ", enter option with 'e'" if select_ui && (options.size > 9)
262
- puts_question("#{question} {{yellow:(#{instructions})}}")
263
- resp = interactive_prompt(options, multiple: multiple, default: default)
264
-
265
- # Clear the line
266
- print(ANSI.previous_line + ANSI.clear_to_end_of_line)
267
- # Force StdoutRouter to prefix
268
- print(ANSI.previous_line + "\n")
269
-
270
- # reset the question to include the answer
271
- resp_text = case resp
272
- when Array
273
- case resp.size
274
- when 0
275
- '<nothing>'
276
- when 1..2
277
- resp.join(' and ')
312
+
313
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
314
+ puts_question("#{question} " + instructions_color.code + "(#{instructions})" + Color::RESET.code)
315
+ resp = interactive_prompt(options, multiple: multiple, default: default)
316
+
317
+ # Clear the line
318
+ print(ANSI.previous_line + ANSI.clear_to_end_of_line)
319
+ # Force StdoutRouter to prefix
320
+ print(ANSI.previous_line + "\n")
321
+
322
+ # reset the question to include the answer
323
+ resp_text = case resp
324
+ when Array
325
+ case resp.size
326
+ when 0
327
+ '<nothing>'
328
+ when 1..2
329
+ resp.join(' and ')
330
+ else
331
+ "#{resp.size} items"
332
+ end
278
333
  else
279
- "#{resp.size} items"
334
+ resp
280
335
  end
281
- else
282
- resp
283
- end
284
- puts_question("#{question} (You chose: {{italic:#{resp_text}}})")
285
-
286
- return T.must(handler).call(resp) if block_given?
336
+ puts_question("#{question} (You chose: {{italic:#{resp_text}}})")
287
337
 
288
- resp
338
+ if block_given?
339
+ T.must(handler).call(resp)
340
+ else
341
+ resp
342
+ end
343
+ end
289
344
  end
290
345
 
291
346
  # Useful for stubbing in tests
@@ -294,13 +349,15 @@ module CLI
294
349
  .returns(T.any(T::Array[String], String))
295
350
  end
296
351
  def interactive_prompt(options, multiple: false, default: nil)
297
- InteractiveOptions.call(options, multiple: multiple, default: default)
352
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
353
+ InteractiveOptions.call(options, multiple: multiple, default: default)
354
+ end
298
355
  end
299
356
 
300
357
  sig { params(default: String).void }
301
358
  def write_default_over_empty_input(default)
302
359
  CLI::UI.raw do
303
- STDERR.puts(
360
+ $stderr.puts(
304
361
  CLI::UI::ANSI.cursor_up(1) +
305
362
  "\r" +
306
363
  CLI::UI::ANSI.cursor_forward(4) + # TODO: width
@@ -312,7 +369,7 @@ module CLI
312
369
 
313
370
  sig { params(str: String).void }
314
371
  def puts_question(str)
315
- STDOUT.puts(CLI::UI.fmt('{{?}} ' + str))
372
+ $stdout.puts(CLI::UI.fmt('{{?}} ' + str))
316
373
  end
317
374
 
318
375
  sig { params(is_file: T::Boolean).returns(String) }
@@ -340,7 +397,7 @@ module CLI
340
397
  print(CLI::UI::Color::RESET.code)
341
398
  line.to_s.chomp
342
399
  rescue Interrupt
343
- CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
400
+ CLI::UI.raw { $stderr.puts('^C' + CLI::UI::Color::RESET.code) }
344
401
  raise
345
402
  end
346
403
  end
@@ -154,4 +154,16 @@ module T
154
154
  def [](type); end
155
155
  end
156
156
  end
157
+
158
+ class << self
159
+ def const_added(name)
160
+ super
161
+ raise 'When using both cli-ui and sorbet, you must require sorbet before cli-ui'
162
+ end
163
+
164
+ def method_added(name)
165
+ super
166
+ raise 'When using both cli-ui and sorbet, you must require sorbet before cli-ui'
167
+ end
168
+ end
157
169
  end
@@ -4,6 +4,41 @@ module CLI
4
4
  module UI
5
5
  module Spinner
6
6
  class SpinGroup
7
+ DEFAULT_FINAL_GLYPH = ->(success) { success ? CLI::UI::Glyph::CHECK.to_s : CLI::UI::Glyph::X.to_s }
8
+
9
+ class << self
10
+ extend T::Sig
11
+
12
+ sig { returns(Mutex) }
13
+ attr_reader :pause_mutex
14
+
15
+ sig { returns(T::Boolean) }
16
+ def paused?
17
+ @paused
18
+ end
19
+
20
+ sig do
21
+ type_parameters(:T)
22
+ .params(block: T.proc.returns(T.type_parameter(:T)))
23
+ .returns(T.type_parameter(:T))
24
+ end
25
+ def pause_spinners(&block)
26
+ previous_paused = T.let(nil, T.nilable(T::Boolean))
27
+ @pause_mutex.synchronize do
28
+ previous_paused = @paused
29
+ @paused = true
30
+ end
31
+ block.call
32
+ ensure
33
+ @pause_mutex.synchronize do
34
+ @paused = previous_paused
35
+ end
36
+ end
37
+ end
38
+
39
+ @pause_mutex = Mutex.new
40
+ @paused = false
41
+
7
42
  extend T::Sig
8
43
 
9
44
  # Initializes a new spin group
@@ -11,7 +46,7 @@ module CLI
11
46
  #
12
47
  # ==== Options
13
48
  #
14
- # * +:auto_debrief+ - Automatically debrief exceptions? Default to true
49
+ # * +:auto_debrief+ - Automatically debrief exceptions or through success_debrief? Default to true
15
50
  #
16
51
  # ==== Example Usage
17
52
  #
@@ -57,12 +92,23 @@ module CLI
57
92
  # * +title+ - Title of the task
58
93
  # * +block+ - Block for the task, will be provided with an instance of the spinner
59
94
  #
60
- sig { params(title: String, block: T.proc.params(task: Task).returns(T.untyped)).void }
61
- def initialize(title, &block)
95
+ sig do
96
+ params(
97
+ title: String,
98
+ final_glyph: T.proc.params(success: T::Boolean).returns(String),
99
+ merged_output: T::Boolean,
100
+ duplicate_output_to: IO,
101
+ block: T.proc.params(task: Task).returns(T.untyped),
102
+ ).void
103
+ end
104
+ def initialize(title, final_glyph:, merged_output:, duplicate_output_to:, &block)
62
105
  @title = title
106
+ @final_glyph = final_glyph
63
107
  @always_full_render = title =~ Formatter::SCAN_WIDGET
64
108
  @thread = Thread.new do
65
- cap = CLI::UI::StdoutRouter::Capture.new(with_frame_inset: false) { block.call(self) }
109
+ cap = CLI::UI::StdoutRouter::Capture.new(
110
+ merged_output: merged_output, duplicate_output_to: duplicate_output_to,
111
+ ) { block.call(self) }
66
112
  begin
67
113
  cap.run
68
114
  ensure
@@ -173,7 +219,7 @@ module CLI
173
219
  sig { params(index: Integer).returns(String) }
174
220
  def glyph(index)
175
221
  if @done
176
- @success ? CLI::UI::Glyph::CHECK.to_s : CLI::UI::Glyph::X.to_s
222
+ @final_glyph.call(@success)
177
223
  else
178
224
  GLYPHS[index]
179
225
  end
@@ -202,10 +248,30 @@ module CLI
202
248
  # spin_group.add('Title') { |spinner| sleep 1.0 }
203
249
  # spin_group.wait
204
250
  #
205
- sig { params(title: String, block: T.proc.params(task: Task).void).void }
206
- def add(title, &block)
251
+ sig do
252
+ params(
253
+ title: String,
254
+ final_glyph: T.proc.params(success: T::Boolean).returns(String),
255
+ merged_output: T::Boolean,
256
+ duplicate_output_to: IO,
257
+ block: T.proc.params(task: Task).void,
258
+ ).void
259
+ end
260
+ def add(
261
+ title,
262
+ final_glyph: DEFAULT_FINAL_GLYPH,
263
+ merged_output: false,
264
+ duplicate_output_to: File.new(File::NULL, 'w'),
265
+ &block
266
+ )
207
267
  @m.synchronize do
208
- @tasks << Task.new(title, &block)
268
+ @tasks << Task.new(
269
+ title,
270
+ final_glyph: final_glyph,
271
+ merged_output: merged_output,
272
+ duplicate_output_to: duplicate_output_to,
273
+ &block
274
+ )
209
275
  end
210
276
  end
211
277
 
@@ -221,32 +287,36 @@ module CLI
221
287
  idx = 0
222
288
 
223
289
  loop do
224
- all_done = T.let(true, T::Boolean)
290
+ done_count = 0
225
291
 
226
292
  width = CLI::UI::Terminal.width
227
293
 
228
- @m.synchronize do
229
- CLI::UI.raw do
230
- @tasks.each.with_index do |task, int_index|
231
- nat_index = int_index + 1
232
- task_done = task.check
233
- all_done = false unless task_done
234
-
235
- if nat_index > @consumed_lines
236
- print(task.render(idx, true, width: width) + "\n")
237
- @consumed_lines += 1
238
- else
239
- offset = @consumed_lines - int_index
240
- move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
241
- move_from = "\r" + CLI::UI::ANSI.cursor_down(offset)
242
-
243
- print(move_to + task.render(idx, idx.zero?, width: width) + move_from)
294
+ self.class.pause_mutex.synchronize do
295
+ next if self.class.paused?
296
+
297
+ @m.synchronize do
298
+ CLI::UI.raw do
299
+ @tasks.each.with_index do |task, int_index|
300
+ nat_index = int_index + 1
301
+ task_done = task.check
302
+ done_count += 1 if task_done
303
+
304
+ if nat_index > @consumed_lines
305
+ print(task.render(idx, true, width: width) + "\n")
306
+ @consumed_lines += 1
307
+ else
308
+ offset = @consumed_lines - int_index
309
+ move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
310
+ move_from = "\r" + CLI::UI::ANSI.cursor_down(offset)
311
+
312
+ print(move_to + task.render(idx, idx.zero?, width: width) + move_from)
313
+ end
244
314
  end
245
315
  end
246
316
  end
247
317
  end
248
318
 
249
- break if all_done
319
+ break if done_count == @tasks.size
250
320
 
251
321
  idx = (idx + 1) % GLYPHS.size
252
322
  Spinner.index = idx
@@ -273,6 +343,16 @@ module CLI
273
343
  @failure_debrief = block
274
344
  end
275
345
 
346
+ # Provide a debriefing for successful tasks
347
+ sig do
348
+ params(
349
+ block: T.proc.params(title: String, out: String, err: String).void,
350
+ ).void
351
+ end
352
+ def success_debrief(&block)
353
+ @success_debrief = block
354
+ end
355
+
276
356
  sig { returns(T::Boolean) }
277
357
  def all_succeeded?
278
358
  @m.synchronize do
@@ -286,13 +366,15 @@ module CLI
286
366
  def debrief
287
367
  @m.synchronize do
288
368
  @tasks.each do |task|
289
- next if task.success
290
-
291
369
  title = task.title
292
- e = task.exception
293
370
  out = task.stdout
294
371
  err = task.stderr
295
372
 
373
+ if task.success
374
+ next @success_debrief&.call(title, out, err)
375
+ end
376
+
377
+ e = task.exception
296
378
  next @failure_debrief.call(title, e, out, err) if @failure_debrief
297
379
 
298
380
  CLI::UI::Frame.open('Task Failed: ' + title, color: :red, timing: Time.new - @start) do
@@ -61,7 +61,7 @@ module CLI
61
61
  #
62
62
  # ==== Options
63
63
  #
64
- # * +:auto_debrief+ - Automatically debrief exceptions? Default to true
64
+ # * +:auto_debrief+ - Automatically debrief exceptions or through success_debrief? Default to true
65
65
  #
66
66
  # ==== Block
67
67
  #
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'cli/ui'
4
4
  require 'stringio'
5
+ require_relative '../../../vendor/reentrant_mutex'
5
6
 
6
7
  module CLI
7
8
  module UI
@@ -35,7 +36,11 @@ module CLI
35
36
 
36
37
  T.unsafe(@stream).write_without_cli_ui(*prepend_id(@stream, args))
37
38
  if (dup = StdoutRouter.duplicate_output_to)
38
- T.unsafe(dup).write(*prepend_id(dup, args))
39
+ begin
40
+ T.unsafe(dup).write(*prepend_id(dup, args))
41
+ rescue IOError
42
+ # Ignore
43
+ end
39
44
  end
40
45
  end
41
46
 
@@ -86,48 +91,122 @@ module CLI
86
91
  class Capture
87
92
  extend T::Sig
88
93
 
89
- @m = Mutex.new
94
+ @capture_mutex = Mutex.new
95
+ @stdin_mutex = ReentrantMutex.new
90
96
  @active_captures = 0
91
97
  @saved_stdin = nil
92
98
 
93
99
  class << self
94
100
  extend T::Sig
95
101
 
102
+ sig { returns(T.nilable(Capture)) }
103
+ def current_capture
104
+ Thread.current[:cliui_current_capture]
105
+ end
106
+
107
+ sig { returns(Capture) }
108
+ def current_capture!
109
+ T.must(current_capture)
110
+ end
111
+
112
+ sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
113
+ def in_alternate_screen(&block)
114
+ stdin_synchronize do
115
+ previous_print_captured_output = current_capture&.print_captured_output
116
+ current_capture&.print_captured_output = true
117
+ Spinner::SpinGroup.pause_spinners do
118
+ if outermost_uncaptured?
119
+ begin
120
+ prev_hook = Thread.current[:cliui_output_hook]
121
+ Thread.current[:cliui_output_hook] = nil
122
+ replay = current_capture!.stdout.gsub(ANSI.match_alternate_screen, '')
123
+ CLI::UI.raw do
124
+ print("#{ANSI.enter_alternate_screen}#{replay}")
125
+ end
126
+ ensure
127
+ Thread.current[:cliui_output_hook] = prev_hook
128
+ end
129
+ end
130
+ block.call
131
+ ensure
132
+ print(ANSI.exit_alternate_screen) if outermost_uncaptured?
133
+ end
134
+ ensure
135
+ current_capture&.print_captured_output = !!previous_print_captured_output
136
+ end
137
+ end
138
+
139
+ sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
140
+ def stdin_synchronize(&block)
141
+ @stdin_mutex.synchronize do
142
+ case $stdin
143
+ when BlockingInput
144
+ $stdin.synchronize do
145
+ block.call
146
+ end
147
+ else
148
+ block.call
149
+ end
150
+ end
151
+ end
152
+
96
153
  sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
97
154
  def with_stdin_masked(&block)
98
- @m.synchronize do
155
+ @capture_mutex.synchronize do
99
156
  if @active_captures.zero?
100
- @saved_stdin = $stdin
101
- $stdin, w = IO.pipe
102
- $stdin.close
103
- w.close
157
+ @stdin_mutex.synchronize do
158
+ @saved_stdin = $stdin
159
+ $stdin = BlockingInput.new(@saved_stdin)
160
+ end
104
161
  end
105
162
  @active_captures += 1
106
163
  end
107
164
 
108
165
  yield
109
166
  ensure
110
- @m.synchronize do
167
+ @capture_mutex.synchronize do
111
168
  @active_captures -= 1
112
169
  if @active_captures.zero?
113
- $stdin = @saved_stdin
170
+ @stdin_mutex.synchronize do
171
+ $stdin = @saved_stdin
172
+ end
114
173
  end
115
174
  end
116
175
  end
176
+
177
+ private
178
+
179
+ sig { returns(T::Boolean) }
180
+ def outermost_uncaptured?
181
+ @stdin_mutex.count == 1 && $stdin.is_a?(BlockingInput)
182
+ end
117
183
  end
118
184
 
119
185
  sig do
120
- params(with_frame_inset: T::Boolean, block: T.proc.void).void
186
+ params(
187
+ with_frame_inset: T::Boolean,
188
+ merged_output: T::Boolean,
189
+ duplicate_output_to: IO,
190
+ block: T.proc.void,
191
+ ).void
121
192
  end
122
- def initialize(with_frame_inset: true, &block)
193
+ def initialize(
194
+ with_frame_inset: true,
195
+ merged_output: false,
196
+ duplicate_output_to: File.open(File::NULL, 'w'),
197
+ &block
198
+ )
123
199
  @with_frame_inset = with_frame_inset
200
+ @merged_output = merged_output
201
+ @duplicate_output_to = duplicate_output_to
124
202
  @block = block
125
- @stdout = ''
126
- @stderr = ''
203
+ @print_captured_output = false
204
+ @out = StringIO.new
205
+ @err = StringIO.new
127
206
  end
128
207
 
129
- sig { returns(String) }
130
- attr_reader :stdout, :stderr
208
+ sig { returns(T::Boolean) }
209
+ attr_accessor :print_captured_output
131
210
 
132
211
  sig { returns(T.untyped) }
133
212
  def run
@@ -135,8 +214,7 @@ module CLI
135
214
 
136
215
  StdoutRouter.assert_enabled!
137
216
 
138
- out = StringIO.new
139
- err = StringIO.new
217
+ Thread.current[:cliui_current_capture] = self
140
218
 
141
219
  prev_frame_inset = Thread.current[:no_cliui_frame_inset]
142
220
  prev_hook = Thread.current[:cliui_output_hook]
@@ -148,24 +226,90 @@ module CLI
148
226
  self.class.with_stdin_masked do
149
227
  Thread.current[:no_cliui_frame_inset] = !@with_frame_inset
150
228
  Thread.current[:cliui_output_hook] = ->(data, stream) do
229
+ stream = :stdout if @merged_output
151
230
  case stream
152
- when :stdout then out.write(data)
153
- when :stderr then err.write(data)
231
+ when :stdout
232
+ @out.write(data)
233
+ @duplicate_output_to.write(data)
234
+ when :stderr
235
+ @err.write(data)
154
236
  else raise
155
237
  end
156
- false # suppress writing to terminal
238
+ print_captured_output # suppress writing to terminal by default
157
239
  end
158
240
 
159
- begin
160
- @block.call
161
- ensure
162
- @stdout = out.string
163
- @stderr = err.string
164
- end
241
+ @block.call
165
242
  end
166
243
  ensure
167
244
  Thread.current[:cliui_output_hook] = prev_hook
168
245
  Thread.current[:no_cliui_frame_inset] = prev_frame_inset
246
+ Thread.current[:cliui_current_capture] = nil
247
+ end
248
+
249
+ sig { returns(String) }
250
+ def stdout
251
+ @out.string
252
+ end
253
+
254
+ sig { returns(String) }
255
+ def stderr
256
+ @err.string
257
+ end
258
+
259
+ class BlockingInput
260
+ extend T::Sig
261
+
262
+ sig { params(stream: IO).void }
263
+ def initialize(stream)
264
+ @stream = stream
265
+ @m = ReentrantMutex.new
266
+ end
267
+
268
+ sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
269
+ def synchronize(&block)
270
+ @m.synchronize do
271
+ previous_allowed_to_read = Thread.current[:cliui_allowed_to_read]
272
+ Thread.current[:cliui_allowed_to_read] = true
273
+ block.call
274
+ ensure
275
+ Thread.current[:cliui_allowed_to_read] = previous_allowed_to_read
276
+ end
277
+ end
278
+
279
+ READING_METHODS = [
280
+ :each,
281
+ :each_byte,
282
+ :each_char,
283
+ :each_codepoint,
284
+ :each_line,
285
+ :getbyte,
286
+ :getc,
287
+ :getch,
288
+ :gets,
289
+ :read,
290
+ :read_nonblock,
291
+ :readbyte,
292
+ :readchar,
293
+ :readline,
294
+ :readlines,
295
+ :readpartial,
296
+ ]
297
+
298
+ NON_READING_METHODS = IO.instance_methods(false) - READING_METHODS
299
+
300
+ READING_METHODS.each do |method|
301
+ define_method(method) do |*args, **kwargs, &block|
302
+ raise(IOError, 'closed stream') unless Thread.current[:cliui_allowed_to_read]
303
+
304
+ @stream.send(method, *args, **kwargs, &block)
305
+ end
306
+ end
307
+
308
+ NON_READING_METHODS.each do |method|
309
+ define_method(method) do |*args, **kwargs, &block|
310
+ @stream.send(method, *args, **kwargs, &block)
311
+ end
312
+ end
169
313
  end
170
314
  end
171
315
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module CLI
4
4
  module UI
5
- VERSION = '2.0.0'
5
+ VERSION = '2.2.0'
6
6
  end
7
7
  end
data/lib/cli/ui.rb CHANGED
@@ -89,6 +89,17 @@ module CLI
89
89
  CLI::UI::Prompt.confirm(question, default: default)
90
90
  end
91
91
 
92
+ # Convenience Method for +CLI::UI::Prompt.any_key+
93
+ #
94
+ # ==== Attributes
95
+ #
96
+ # * +prompt+ - prompt to present
97
+ #
98
+ sig { params(prompt: String).returns(T.nilable(String)) }
99
+ def any_key(prompt = 'Press any key to continue')
100
+ CLI::UI::Prompt.any_key(prompt)
101
+ end
102
+
92
103
  # Convenience Method for +CLI::UI::Prompt.ask+
93
104
  sig do
94
105
  params(
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cli-ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Burke Libbey
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: exe
12
12
  cert_chain: []
13
- date: 2022-11-18 00:00:00.000000000 Z
13
+ date: 2023-04-17 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: minitest