cli-ui 1.5.1 → 2.2.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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