cli-ui 1.5.1 → 2.2.3

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.
@@ -1,24 +1,27 @@
1
+ # typed: true
2
+
1
3
  require 'cli/ui'
2
4
  require 'stringio'
5
+ require_relative '../../../vendor/reentrant_mutex'
3
6
 
4
7
  module CLI
5
8
  module UI
6
9
  module StdoutRouter
7
- class << self
8
- attr_accessor :duplicate_output_to
9
- end
10
-
11
10
  class Writer
11
+ extend T::Sig
12
+
13
+ sig { params(stream: IOLike, name: Symbol).void }
12
14
  def initialize(stream, name)
13
15
  @stream = stream
14
16
  @name = name
15
17
  end
16
18
 
19
+ sig { params(args: Object).returns(Integer) }
17
20
  def write(*args)
18
21
  args = args.map do |str|
19
22
  if auto_frame_inset?
20
23
  str = str.dup # unfreeze
21
- str = str.force_encoding(Encoding::UTF_8)
24
+ str = str.to_s.force_encoding(Encoding::UTF_8)
22
25
  apply_line_prefix(str, CLI::UI::Frame.prefix)
23
26
  else
24
27
  @pending_newline = false
@@ -28,37 +31,50 @@ module CLI
28
31
 
29
32
  # hook return of false suppresses output.
30
33
  if (hook = Thread.current[:cliui_output_hook])
31
- return if hook.call(args.map(&:to_s).join, @name) == false
34
+ return 0 if hook.call(args.map(&:to_s).join, @name) == false
32
35
  end
33
36
 
34
- @stream.write_without_cli_ui(*prepend_id(@stream, args))
37
+ ret = T.unsafe(@stream).write_without_cli_ui(*prepend_id(@stream, args))
35
38
  if (dup = StdoutRouter.duplicate_output_to)
36
- dup.write(*prepend_id(dup, args))
39
+ begin
40
+ T.unsafe(dup).write(*prepend_id(dup, args))
41
+ rescue IOError
42
+ # Ignore
43
+ end
37
44
  end
45
+ ret
38
46
  end
39
47
 
40
48
  private
41
49
 
50
+ sig { params(stream: IOLike, args: T::Array[String]).returns(T::Array[String]) }
42
51
  def prepend_id(stream, args)
43
52
  return args unless prepend_id_for_stream(stream)
53
+
44
54
  args.map do |a|
45
55
  next a if a.chomp.empty? # allow new lines to be new lines
56
+
46
57
  "[#{Thread.current[:cliui_output_id][:id]}] #{a}"
47
58
  end
48
59
  end
49
60
 
61
+ sig { params(stream: IOLike).returns(T::Boolean) }
50
62
  def prepend_id_for_stream(stream)
51
63
  return false unless Thread.current[:cliui_output_id]
52
64
  return true if Thread.current[:cliui_output_id][:streams].include?(stream)
65
+
53
66
  false
54
67
  end
55
68
 
69
+ sig { returns(T::Boolean) }
56
70
  def auto_frame_inset?
57
71
  !Thread.current[:no_cliui_frame_inset]
58
72
  end
59
73
 
74
+ sig { params(str: String, prefix: String).returns(String) }
60
75
  def apply_line_prefix(str, prefix)
61
76
  return '' if str.empty?
77
+
62
78
  prefixed = +''
63
79
  str.force_encoding(Encoding::UTF_8).lines.each do |line|
64
80
  if @pending_newline
@@ -74,46 +90,132 @@ module CLI
74
90
  end
75
91
 
76
92
  class Capture
77
- @m = Mutex.new
93
+ extend T::Sig
94
+
95
+ @capture_mutex = Mutex.new
96
+ @stdin_mutex = CLI::UI::ReentrantMutex.new
78
97
  @active_captures = 0
79
98
  @saved_stdin = nil
80
99
 
81
- def self.with_stdin_masked
82
- @m.synchronize do
83
- if @active_captures.zero?
84
- @saved_stdin = $stdin
85
- $stdin, w = IO.pipe
86
- $stdin.close
87
- w.close
100
+ class << self
101
+ extend T::Sig
102
+
103
+ sig { returns(T.nilable(Capture)) }
104
+ def current_capture
105
+ Thread.current[:cliui_current_capture]
106
+ end
107
+
108
+ sig { returns(Capture) }
109
+ def current_capture!
110
+ T.must(current_capture)
111
+ end
112
+
113
+ sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
114
+ def in_alternate_screen(&block)
115
+ stdin_synchronize do
116
+ previous_print_captured_output = current_capture&.print_captured_output
117
+ current_capture&.print_captured_output = true
118
+ Spinner::SpinGroup.pause_spinners do
119
+ if outermost_uncaptured?
120
+ begin
121
+ prev_hook = Thread.current[:cliui_output_hook]
122
+ Thread.current[:cliui_output_hook] = nil
123
+ replay = current_capture!.stdout.gsub(ANSI.match_alternate_screen, '')
124
+ CLI::UI.raw do
125
+ print("#{ANSI.enter_alternate_screen}#{replay}")
126
+ end
127
+ ensure
128
+ Thread.current[:cliui_output_hook] = prev_hook
129
+ end
130
+ end
131
+ block.call
132
+ ensure
133
+ print(ANSI.exit_alternate_screen) if outermost_uncaptured?
134
+ end
135
+ ensure
136
+ current_capture&.print_captured_output = !!previous_print_captured_output
88
137
  end
89
- @active_captures += 1
90
138
  end
91
139
 
92
- yield
93
- ensure
94
- @m.synchronize do
95
- @active_captures -= 1
96
- if @active_captures.zero?
97
- $stdin = @saved_stdin
140
+ sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
141
+ def stdin_synchronize(&block)
142
+ @stdin_mutex.synchronize do
143
+ case $stdin
144
+ when BlockingInput
145
+ $stdin.synchronize do
146
+ block.call
147
+ end
148
+ else
149
+ block.call
150
+ end
98
151
  end
99
152
  end
153
+
154
+ sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
155
+ def with_stdin_masked(&block)
156
+ @capture_mutex.synchronize do
157
+ if @active_captures.zero?
158
+ @stdin_mutex.synchronize do
159
+ @saved_stdin = $stdin
160
+ $stdin = BlockingInput.new(@saved_stdin)
161
+ end
162
+ end
163
+ @active_captures += 1
164
+ end
165
+
166
+ yield
167
+ ensure
168
+ @capture_mutex.synchronize do
169
+ @active_captures -= 1
170
+ if @active_captures.zero?
171
+ @stdin_mutex.synchronize do
172
+ $stdin = @saved_stdin
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ private
179
+
180
+ sig { returns(T::Boolean) }
181
+ def outermost_uncaptured?
182
+ @stdin_mutex.count == 1 && $stdin.is_a?(BlockingInput)
183
+ end
100
184
  end
101
185
 
102
- def initialize(*block_args, with_frame_inset: true, &block)
186
+ sig do
187
+ params(
188
+ with_frame_inset: T::Boolean,
189
+ merged_output: T::Boolean,
190
+ duplicate_output_to: IO,
191
+ block: T.proc.void,
192
+ ).void
193
+ end
194
+ def initialize(
195
+ with_frame_inset: true,
196
+ merged_output: false,
197
+ duplicate_output_to: File.open(File::NULL, 'w'),
198
+ &block
199
+ )
103
200
  @with_frame_inset = with_frame_inset
104
- @block_args = block_args
201
+ @merged_output = merged_output
202
+ @duplicate_output_to = duplicate_output_to
105
203
  @block = block
204
+ @print_captured_output = false
205
+ @out = StringIO.new
206
+ @err = StringIO.new
106
207
  end
107
208
 
108
- attr_reader :stdout, :stderr
209
+ sig { returns(T::Boolean) }
210
+ attr_accessor :print_captured_output
109
211
 
212
+ sig { returns(T.untyped) }
110
213
  def run
111
214
  require 'stringio'
112
215
 
113
216
  StdoutRouter.assert_enabled!
114
217
 
115
- out = StringIO.new
116
- err = StringIO.new
218
+ Thread.current[:cliui_current_capture] = self
117
219
 
118
220
  prev_frame_inset = Thread.current[:no_cliui_frame_inset]
119
221
  prev_hook = Thread.current[:cliui_output_hook]
@@ -125,61 +227,132 @@ module CLI
125
227
  self.class.with_stdin_masked do
126
228
  Thread.current[:no_cliui_frame_inset] = !@with_frame_inset
127
229
  Thread.current[:cliui_output_hook] = ->(data, stream) do
230
+ stream = :stdout if @merged_output
128
231
  case stream
129
- when :stdout then out.write(data)
130
- when :stderr then err.write(data)
232
+ when :stdout
233
+ @out.write(data)
234
+ @duplicate_output_to.write(data)
235
+ when :stderr
236
+ @err.write(data)
131
237
  else raise
132
238
  end
133
- false # suppress writing to terminal
239
+ print_captured_output # suppress writing to terminal by default
134
240
  end
135
241
 
136
- begin
137
- @block.call(*@block_args)
138
- ensure
139
- @stdout = out.string
140
- @stderr = err.string
141
- end
242
+ @block.call
142
243
  end
143
244
  ensure
144
245
  Thread.current[:cliui_output_hook] = prev_hook
145
246
  Thread.current[:no_cliui_frame_inset] = prev_frame_inset
247
+ Thread.current[:cliui_current_capture] = nil
248
+ end
249
+
250
+ sig { returns(String) }
251
+ def stdout
252
+ @out.string
253
+ end
254
+
255
+ sig { returns(String) }
256
+ def stderr
257
+ @err.string
258
+ end
259
+
260
+ class BlockingInput
261
+ extend T::Sig
262
+
263
+ sig { params(stream: IO).void }
264
+ def initialize(stream)
265
+ @stream = stream
266
+ @m = CLI::UI::ReentrantMutex.new
267
+ end
268
+
269
+ sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
270
+ def synchronize(&block)
271
+ @m.synchronize do
272
+ previous_allowed_to_read = Thread.current[:cliui_allowed_to_read]
273
+ Thread.current[:cliui_allowed_to_read] = true
274
+ block.call
275
+ ensure
276
+ Thread.current[:cliui_allowed_to_read] = previous_allowed_to_read
277
+ end
278
+ end
279
+
280
+ READING_METHODS = [
281
+ :each,
282
+ :each_byte,
283
+ :each_char,
284
+ :each_codepoint,
285
+ :each_line,
286
+ :getbyte,
287
+ :getc,
288
+ :getch,
289
+ :gets,
290
+ :read,
291
+ :read_nonblock,
292
+ :readbyte,
293
+ :readchar,
294
+ :readline,
295
+ :readlines,
296
+ :readpartial,
297
+ ]
298
+
299
+ NON_READING_METHODS = IO.instance_methods(false) - READING_METHODS
300
+
301
+ READING_METHODS.each do |method|
302
+ define_method(method) do |*args, **kwargs, &block|
303
+ raise(IOError, 'closed stream') unless Thread.current[:cliui_allowed_to_read]
304
+
305
+ @stream.send(method, *args, **kwargs, &block)
306
+ end
307
+ end
308
+
309
+ NON_READING_METHODS.each do |method|
310
+ define_method(method) do |*args, **kwargs, &block|
311
+ @stream.send(method, *args, **kwargs, &block)
312
+ end
313
+ end
146
314
  end
147
315
  end
148
316
 
149
317
  class << self
318
+ extend T::Sig
319
+
150
320
  WRITE_WITHOUT_CLI_UI = :write_without_cli_ui
151
321
 
152
322
  NotEnabled = Class.new(StandardError)
153
323
 
154
- def with_id(on_streams:)
155
- unless on_streams.is_a?(Array) && on_streams.all? { |s| s.respond_to?(:write) }
156
- raise ArgumentError, <<~EOF
157
- on_streams must be an array of objects that respond to `write`
158
- These do not respond to write
159
- #{on_streams.reject { |s| s.respond_to?(:write) }.map.with_index { |s| s.class.to_s }.join("\n")}
160
- EOF
161
- end
324
+ sig { returns(T.nilable(IOLike)) }
325
+ attr_accessor :duplicate_output_to
162
326
 
327
+ sig do
328
+ type_parameters(:T)
329
+ .params(on_streams: T::Array[IOLike], block: T.proc.params(id: String).returns(T.type_parameter(:T)))
330
+ .returns(T.type_parameter(:T))
331
+ end
332
+ def with_id(on_streams:, &block)
163
333
  require 'securerandom'
164
334
  id = format('%05d', rand(10**5))
165
335
  Thread.current[:cliui_output_id] = {
166
336
  id: id,
167
- streams: on_streams,
337
+ streams: on_streams.map { |stream| T.cast(stream, IOLike) },
168
338
  }
169
339
  yield(id)
170
340
  ensure
171
341
  Thread.current[:cliui_output_id] = nil
172
342
  end
173
343
 
344
+ sig { returns(T.nilable(T::Hash[Symbol, T.any(String, IOLike)])) }
174
345
  def current_id
175
346
  Thread.current[:cliui_output_id]
176
347
  end
177
348
 
349
+ sig { void }
178
350
  def assert_enabled!
179
351
  raise NotEnabled unless enabled?
180
352
  end
181
353
 
182
- def with_enabled
354
+ sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
355
+ def with_enabled(&block)
183
356
  enable
184
357
  yield
185
358
  ensure
@@ -187,23 +360,29 @@ module CLI
187
360
  end
188
361
 
189
362
  # TODO: remove this
363
+ sig { void }
190
364
  def ensure_activated
191
365
  enable unless enabled?
192
366
  end
193
367
 
368
+ sig { returns(T::Boolean) }
194
369
  def enable
195
370
  return false if enabled?($stdout) || enabled?($stderr)
371
+
196
372
  activate($stdout, :stdout)
197
373
  activate($stderr, :stderr)
198
374
  true
199
375
  end
200
376
 
377
+ sig { params(stream: IOLike).returns(T::Boolean) }
201
378
  def enabled?(stream = $stdout)
202
379
  stream.respond_to?(WRITE_WITHOUT_CLI_UI)
203
380
  end
204
381
 
382
+ sig { returns(T::Boolean) }
205
383
  def disable
206
384
  return false unless enabled?($stdout) && enabled?($stderr)
385
+
207
386
  deactivate($stdout)
208
387
  deactivate($stderr)
209
388
  true
@@ -211,16 +390,19 @@ module CLI
211
390
 
212
391
  private
213
392
 
393
+ sig { params(stream: IOLike).void }
214
394
  def deactivate(stream)
215
395
  sc = stream.singleton_class
216
396
  sc.send(:remove_method, :write)
217
397
  sc.send(:alias_method, :write, WRITE_WITHOUT_CLI_UI)
218
398
  end
219
399
 
400
+ sig { params(stream: IOLike, streamname: Symbol).void }
220
401
  def activate(stream, streamname)
221
402
  writer = StdoutRouter::Writer.new(stream, streamname)
222
403
 
223
404
  raise if stream.respond_to?(WRITE_WITHOUT_CLI_UI)
405
+
224
406
  stream.singleton_class.send(:alias_method, WRITE_WITHOUT_CLI_UI, :write)
225
407
  stream.define_singleton_method(:write) do |*args|
226
408
  writer.write(*args)
@@ -1,44 +1,56 @@
1
+ # typed: true
2
+
1
3
  require 'cli/ui'
2
4
  require 'io/console'
3
5
 
4
6
  module CLI
5
7
  module UI
6
8
  module Terminal
9
+ extend T::Sig
10
+
7
11
  DEFAULT_WIDTH = 80
8
12
  DEFAULT_HEIGHT = 24
9
13
 
10
- # Returns the width of the terminal, if possible
11
- # Otherwise will return DEFAULT_WIDTH
12
- #
13
- def self.width
14
- winsize[1]
15
- end
14
+ class << self
15
+ extend T::Sig
16
16
 
17
- # Returns the width of the terminal, if possible
18
- # Otherwise, will return DEFAULT_HEIGHT
19
- #
20
- def self.height
21
- winsize[0]
22
- end
17
+ # Returns the width of the terminal, if possible
18
+ # Otherwise will return DEFAULT_WIDTH
19
+ #
20
+ sig { returns(Integer) }
21
+ def width
22
+ winsize[1]
23
+ end
23
24
 
24
- def self.winsize
25
- @winsize ||= begin
26
- winsize = IO.console.winsize
27
- setup_winsize_trap
25
+ # Returns the width of the terminal, if possible
26
+ # Otherwise, will return DEFAULT_HEIGHT
27
+ #
28
+ sig { returns(Integer) }
29
+ def height
30
+ winsize[0]
31
+ end
32
+
33
+ sig { returns([Integer, Integer]) }
34
+ def winsize
35
+ @winsize ||= begin
36
+ winsize = IO.console.winsize
37
+ setup_winsize_trap
28
38
 
29
- if winsize.any?(&:zero?)
39
+ if winsize.any?(&:zero?)
40
+ [DEFAULT_HEIGHT, DEFAULT_WIDTH]
41
+ else
42
+ winsize
43
+ end
44
+ rescue
30
45
  [DEFAULT_HEIGHT, DEFAULT_WIDTH]
31
- else
32
- winsize
33
46
  end
34
- rescue
35
- [DEFAULT_HEIGHT, DEFAULT_WIDTH]
36
47
  end
37
- end
38
48
 
39
- def self.setup_winsize_trap
40
- @winsize_trap ||= Signal.trap('WINCH') do
41
- @winsize = nil
49
+ sig { void }
50
+ def setup_winsize_trap
51
+ @winsize_trap ||= Signal.trap('WINCH') do
52
+ @winsize = nil
53
+ end
42
54
  end
43
55
  end
44
56
  end
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'cli/ui'
@@ -27,12 +28,15 @@ module CLI
27
28
  TRUNCATED = "\x1b[0m…"
28
29
 
29
30
  class << self
31
+ extend T::Sig
32
+
33
+ sig { params(text: String, printing_width: Integer).returns(String) }
30
34
  def call(text, printing_width)
31
35
  return text if text.size <= printing_width
32
36
 
33
37
  width = 0
34
38
  mode = PARSE_ROOT
35
- truncation_index = nil
39
+ truncation_index = T.let(nil, T.nilable(Integer))
36
40
 
37
41
  codepoints = text.codepoints
38
42
  codepoints.each.with_index do |cp, index|
@@ -83,11 +87,12 @@ module CLI
83
87
  # the end of the string.
84
88
  return text if !truncation_index || width <= printing_width
85
89
 
86
- codepoints[0...truncation_index].pack('U*') + TRUNCATED
90
+ T.must(codepoints[0...truncation_index]).pack('U*') + TRUNCATED
87
91
  end
88
92
 
89
93
  private
90
94
 
95
+ sig { params(printable_codepoint: Integer).returns(Integer) }
91
96
  def width(printable_codepoint)
92
97
  case printable_codepoint
93
98
  when EMOJI_RANGE
@@ -1,5 +1,7 @@
1
+ # typed: true
2
+
1
3
  module CLI
2
4
  module UI
3
- VERSION = '1.5.1'
5
+ VERSION = '2.2.3'
4
6
  end
5
7
  end
@@ -1,26 +1,45 @@
1
+ # typed: true
2
+
1
3
  require('cli/ui')
2
4
 
3
5
  module CLI
4
6
  module UI
5
7
  module Widgets
6
8
  class Base
7
- def self.call(argstring)
8
- new(argstring).render
9
+ extend T::Sig
10
+ extend T::Helpers
11
+ abstract!
12
+
13
+ class << self
14
+ extend T::Sig
15
+
16
+ sig { params(argstring: String).returns(String) }
17
+ def call(argstring)
18
+ new(argstring).render
19
+ end
9
20
  end
10
21
 
22
+ sig { params(argstring: String).void }
11
23
  def initialize(argstring)
12
24
  pat = self.class.argparse_pattern
13
25
  unless (@match_data = pat.match(argstring))
14
26
  raise(Widgets::InvalidWidgetArguments.new(argstring, pat))
15
27
  end
28
+
16
29
  @match_data.names.each do |name|
17
30
  instance_variable_set(:"@#{name}", @match_data[name])
18
31
  end
19
32
  end
20
33
 
21
- def self.argparse_pattern
22
- const_get(:ARGPARSE_PATTERN)
34
+ class << self
35
+ extend T::Sig
36
+
37
+ sig { abstract.returns(Regexp) }
38
+ def argparse_pattern; end
23
39
  end
40
+
41
+ sig { abstract.returns(String) }
42
+ def render; end
24
43
  end
25
44
  end
26
45
  end
@@ -1,4 +1,6 @@
1
- # frozen-string-literal: true
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
2
4
  require('cli/ui')
3
5
 
4
6
  module CLI
@@ -19,6 +21,16 @@ module CLI
19
21
  SPINNER_STOPPED = '⠿'
20
22
  EMPTY_SET = '∅'
21
23
 
24
+ class << self
25
+ extend T::Sig
26
+
27
+ sig { override.returns(Regexp) }
28
+ def argparse_pattern
29
+ ARGPARSE_PATTERN
30
+ end
31
+ end
32
+
33
+ sig { override.returns(String) }
22
34
  def render
23
35
  if zero?(@succeeded) && zero?(@failed) && zero?(@working) && zero?(@pending)
24
36
  Color::RESET.code + Color::BOLD.code + EMPTY_SET + Color::RESET.code
@@ -30,28 +42,34 @@ module CLI
30
42
 
31
43
  private
32
44
 
45
+ sig { params(num_str: String).returns(T::Boolean) }
33
46
  def zero?(num_str)
34
47
  num_str == '0'
35
48
  end
36
49
 
50
+ sig { params(num_str: String, rune: String, color: Color).returns(String) }
37
51
  def colorize_if_nonzero(num_str, rune, color)
38
52
  color = Color::GRAY if zero?(num_str)
39
53
  color.code + num_str + rune
40
54
  end
41
55
 
56
+ sig { returns(String) }
42
57
  def succeeded_part
43
58
  colorize_if_nonzero(@succeeded, Glyph::CHECK.char, Color::GREEN)
44
59
  end
45
60
 
61
+ sig { returns(String) }
46
62
  def failed_part
47
63
  colorize_if_nonzero(@failed, Glyph::X.char, Color::RED)
48
64
  end
49
65
 
66
+ sig { returns(String) }
50
67
  def working_part
51
68
  rune = zero?(@working) ? SPINNER_STOPPED : Spinner.current_rune
52
69
  colorize_if_nonzero(@working, rune, Color::BLUE)
53
70
  end
54
71
 
72
+ sig { returns(String) }
55
73
  def pending_part
56
74
  colorize_if_nonzero(@pending, Glyph::HOURGLASS.char, Color::WHITE)
57
75
  end