cli-ui 2.0.0 → 2.2.0

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