cli-ui 1.2.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,8 +15,13 @@ module CLI
15
15
  @options[option] = handler
16
16
  end
17
17
 
18
- def call(option)
19
- @options[option].call(option)
18
+ def call(options)
19
+ case options
20
+ when Array
21
+ options.map { |option| @options[option].call(options) }
22
+ else
23
+ @options[options].call(options)
24
+ end
20
25
  end
21
26
  end
22
27
  end
@@ -1,3 +1,4 @@
1
+ # frozen-string-literal: true
1
2
  require 'cli/ui'
2
3
 
3
4
  module CLI
@@ -9,11 +10,28 @@ module CLI
9
10
  PERIOD = 0.1 # seconds
10
11
  TASK_FAILED = :task_failed
11
12
 
12
- begin
13
- runes = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
14
- colors = [CLI::UI::Color::CYAN.code] * 5 + [CLI::UI::Color::MAGENTA.code] * 5
15
- raise unless runes.size == colors.size
16
- GLYPHS = colors.zip(runes).map(&:join)
13
+ RUNES = CLI::UI::OS.current.supports_emoji? ? %w(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏).freeze : %w(\\ | / - \\ | / -).freeze
14
+
15
+ colors = [CLI::UI::Color::CYAN.code] * (RUNES.size / 2).ceil +
16
+ [CLI::UI::Color::MAGENTA.code] * (RUNES.size / 2).to_i
17
+ GLYPHS = colors.zip(RUNES).map(&:join)
18
+
19
+ class << self
20
+ attr_accessor(:index)
21
+
22
+ # We use this from CLI::UI::Widgets::Status to render an additional
23
+ # spinner next to the "working" element. While this global state looks
24
+ # a bit repulsive at first, it's worth realizing that:
25
+ #
26
+ # * It's managed by the SpinGroup#wait method, not individual tasks; and
27
+ # * It would be complete insanity to run two separate but concurrent SpinGroups.
28
+ #
29
+ # While it would be possible to stitch through some connection between
30
+ # the SpinGroup and the Widgets included in its title, this is simpler
31
+ # in practice and seems unlikely to cause issues in practice.
32
+ def current_rune
33
+ RUNES[index || 0]
34
+ end
17
35
  end
18
36
 
19
37
  # Adds a single spinner
@@ -25,6 +25,7 @@ module CLI
25
25
  @consumed_lines = 0
26
26
  @tasks = []
27
27
  @auto_debrief = auto_debrief
28
+ @start = Time.new
28
29
  end
29
30
 
30
31
  class Task
@@ -40,6 +41,7 @@ module CLI
40
41
  #
41
42
  def initialize(title, &block)
42
43
  @title = title
44
+ @always_full_render = title =~ Formatter::SCAN_WIDGET
43
45
  @thread = Thread.new do
44
46
  cap = CLI::UI::StdoutRouter::Capture.new(self, with_frame_inset: false, &block)
45
47
  begin
@@ -50,8 +52,9 @@ module CLI
50
52
  end
51
53
  end
52
54
 
55
+ @m = Mutex.new
53
56
  @force_full_render = false
54
- @done = false
57
+ @done = false
55
58
  @exception = nil
56
59
  @success = false
57
60
  end
@@ -75,7 +78,17 @@ module CLI
75
78
  @done
76
79
  end
77
80
 
78
- # Re-renders the task if required
81
+ # Re-renders the task if required:
82
+ #
83
+ # We try to be as lazy as possible in re-rendering the full line. The
84
+ # spinner rune will change on each render for the most part, but the
85
+ # body text will rarely have changed. If the body text *has* changed,
86
+ # we set @force_full_render.
87
+ #
88
+ # Further, if the title string includes any CLI::UI::Widgets, we
89
+ # assume that it may change from render to render, since those
90
+ # evaluate more dynamically than the rest of our format codes, which
91
+ # are just text formatters. This is controlled by @always_full_render.
79
92
  #
80
93
  # ==== Attributes
81
94
  #
@@ -84,10 +97,15 @@ module CLI
84
97
  # * +width+ - current terminal width to format for
85
98
  #
86
99
  def render(index, force = true, width: CLI::UI::Terminal.width)
87
- return full_render(index, width) if force || @force_full_render
88
- partial_render(index)
89
- ensure
90
- @force_full_render = false
100
+ @m.synchronize do
101
+ if force || @always_full_render || @force_full_render
102
+ full_render(index, width)
103
+ else
104
+ partial_render(index)
105
+ end
106
+ ensure
107
+ @force_full_render = false
108
+ end
91
109
  end
92
110
 
93
111
  # Update the spinner title
@@ -97,8 +115,11 @@ module CLI
97
115
  # * +title+ - title to change the spinner to
98
116
  #
99
117
  def update_title(new_title)
100
- @title = new_title
101
- @force_full_render = true
118
+ @m.synchronize do
119
+ @always_full_render = new_title =~ Formatter::SCAN_WIDGET
120
+ @title = new_title
121
+ @force_full_render = true
122
+ end
102
123
  end
103
124
 
104
125
  private
@@ -182,7 +203,7 @@ module CLI
182
203
  @consumed_lines += 1
183
204
  else
184
205
  offset = @consumed_lines - int_index
185
- move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
206
+ move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
186
207
  move_from = "\r" + CLI::UI::ANSI.cursor_down(offset)
187
208
 
188
209
  print(move_to + task.render(idx, idx.zero?, width: width) + move_from)
@@ -194,6 +215,7 @@ module CLI
194
215
  break if all_done
195
216
 
196
217
  idx = (idx + 1) % GLYPHS.size
218
+ Spinner.index = idx
197
219
  sleep(PERIOD)
198
220
  end
199
221
 
@@ -217,18 +239,18 @@ module CLI
217
239
  out = task.stdout
218
240
  err = task.stderr
219
241
 
220
- CLI::UI::Frame.open('Task Failed: ' + task.title, color: :red) do
242
+ CLI::UI::Frame.open('Task Failed: ' + task.title, color: :red, timing: Time.new - @start) do
221
243
  if e
222
244
  puts "#{e.class}: #{e.message}"
223
245
  puts "\tfrom #{e.backtrace.join("\n\tfrom ")}"
224
246
  end
225
247
 
226
248
  CLI::UI::Frame.divider('STDOUT')
227
- out = "(empty)" if out.nil? || out.strip.empty?
249
+ out = '(empty)' if out.nil? || out.strip.empty?
228
250
  puts out
229
251
 
230
252
  CLI::UI::Frame.divider('STDERR')
231
- err = "(empty)" if err.nil? || err.strip.empty?
253
+ err = '(empty)' if err.nil? || err.strip.empty?
232
254
  puts err
233
255
  end
234
256
  end
@@ -26,13 +26,14 @@ module CLI
26
26
  end
27
27
  end
28
28
 
29
- hook = Thread.current[:cliui_output_hook]
30
29
  # hook return of false suppresses output.
31
- if !hook || hook.call(args.first, @name) != false
32
- @stream.write_without_cli_ui(*prepend_id(@stream, args))
33
- if dup = StdoutRouter.duplicate_output_to
34
- dup.write(*prepend_id(dup, args))
35
- end
30
+ if (hook = Thread.current[:cliui_output_hook])
31
+ return if hook.call(args.map(&:to_s).join, @name) == false
32
+ end
33
+
34
+ @stream.write_without_cli_ui(*prepend_id(@stream, args))
35
+ if (dup = StdoutRouter.duplicate_output_to)
36
+ dup.write(*prepend_id(dup, args))
36
37
  end
37
38
  end
38
39
 
@@ -117,6 +118,10 @@ module CLI
117
118
  prev_frame_inset = Thread.current[:no_cliui_frame_inset]
118
119
  prev_hook = Thread.current[:cliui_output_hook]
119
120
 
121
+ if Thread.current.respond_to?(:report_on_exception)
122
+ Thread.current.report_on_exception = false
123
+ end
124
+
120
125
  self.class.with_stdin_masked do
121
126
  Thread.current[:no_cliui_frame_inset] = !@with_frame_inset
122
127
  Thread.current[:cliui_output_hook] = ->(data, stream) do
@@ -156,10 +161,10 @@ module CLI
156
161
  end
157
162
 
158
163
  require 'securerandom'
159
- id = format("%05d", rand(10**5))
164
+ id = format('%05d', rand(10**5))
160
165
  Thread.current[:cliui_output_id] = {
161
166
  id: id,
162
- streams: on_streams
167
+ streams: on_streams,
163
168
  }
164
169
  yield(id)
165
170
  ensure
@@ -8,28 +8,38 @@ module CLI
8
8
  DEFAULT_HEIGHT = 24
9
9
 
10
10
  # Returns the width of the terminal, if possible
11
- # Otherwise will return 80
11
+ # Otherwise will return DEFAULT_WIDTH
12
12
  #
13
13
  def self.width
14
- if console = IO.respond_to?(:console) && IO.console
15
- width = console.winsize[1]
16
- width.zero? ? DEFAULT_WIDTH : width
17
- else
18
- DEFAULT_WIDTH
19
- end
20
- rescue Errno::EIO
21
- DEFAULT_WIDTH
14
+ winsize[1]
22
15
  end
23
16
 
17
+ # Returns the width of the terminal, if possible
18
+ # Otherwise, will return DEFAULT_HEIGHT
19
+ #
24
20
  def self.height
25
- if console = IO.respond_to?(:console) && IO.console
26
- height = console.winsize[0]
27
- height.zero? ? DEFAULT_HEIGHT : height
28
- else
29
- DEFAULT_HEIGHT
21
+ winsize[0]
22
+ end
23
+
24
+ def self.winsize
25
+ @winsize ||= begin
26
+ winsize = IO.console.winsize
27
+ setup_winsize_trap
28
+
29
+ if winsize.any?(&:zero?)
30
+ [DEFAULT_HEIGHT, DEFAULT_WIDTH]
31
+ else
32
+ winsize
33
+ end
34
+ rescue
35
+ [DEFAULT_HEIGHT, DEFAULT_WIDTH]
36
+ end
37
+ end
38
+
39
+ def self.setup_winsize_trap
40
+ @winsize_trap ||= Signal.trap('WINCH') do
41
+ @winsize = nil
30
42
  end
31
- rescue Errno::EIO
32
- DEFAULT_HEIGHT
33
43
  end
34
44
  end
35
45
  end
@@ -52,11 +52,11 @@ module CLI
52
52
  end
53
53
  end
54
54
  when PARSE_ESC
55
- case cp
55
+ mode = case cp
56
56
  when LEFT_SQUARE_BRACKET
57
- mode = PARSE_ANSI
57
+ PARSE_ANSI
58
58
  else
59
- mode = PARSE_ROOT
59
+ PARSE_ROOT
60
60
  end
61
61
  when PARSE_ANSI
62
62
  # ANSI escape codes preeeetty much have the format of:
@@ -83,7 +83,7 @@ module CLI
83
83
  # the end of the string.
84
84
  return text if !truncation_index || width <= printing_width
85
85
 
86
- codepoints[0...truncation_index].pack("U*") + TRUNCATED
86
+ codepoints[0...truncation_index].pack('U*') + TRUNCATED
87
87
  end
88
88
 
89
89
  private
@@ -1,5 +1,5 @@
1
1
  module CLI
2
2
  module UI
3
- VERSION = "1.2.2"
3
+ VERSION = '1.5.1'
4
4
  end
5
5
  end
@@ -0,0 +1,77 @@
1
+ require('cli/ui')
2
+
3
+ module CLI
4
+ module UI
5
+ # Widgets are formatter objects with more custom implementations than the
6
+ # other features, which all center around formatting text with colours,
7
+ # etc.
8
+ #
9
+ # If you want to extend CLI::UI with your own widgets, you may want to do
10
+ # something like this:
11
+ #
12
+ # require('cli/ui')
13
+ # class MyWidget < CLI::UI::Widgets::Base
14
+ # # ...
15
+ # end
16
+ # CLI::UI::Widgets.register('my-widget') { MyWidget }
17
+ # puts(CLI::UI.fmt("{{@widget/my-widget:args}}"))
18
+ module Widgets
19
+ MAP = {}
20
+
21
+ autoload(:Base, 'cli/ui/widgets/base')
22
+
23
+ def self.register(name, &cb)
24
+ MAP[name] = cb
25
+ end
26
+
27
+ autoload(:Status, 'cli/ui/widgets/status')
28
+ register('status') { Widgets::Status }
29
+
30
+ # Looks up a widget by handle
31
+ #
32
+ # ==== Raises
33
+ # Raises InvalidWidgetHandle if the widget is not available.
34
+ #
35
+ # ==== Returns
36
+ # A callable widget, to be invoked like `.call(argstring)`
37
+ #
38
+ def self.lookup(handle)
39
+ MAP.fetch(handle.to_s).call
40
+ rescue KeyError, NameError
41
+ raise(InvalidWidgetHandle, handle)
42
+ end
43
+
44
+ # All available widgets by name
45
+ #
46
+ def self.available
47
+ MAP.keys
48
+ end
49
+
50
+ class InvalidWidgetHandle < ArgumentError
51
+ def initialize(handle)
52
+ super
53
+ @handle = handle
54
+ end
55
+
56
+ def message
57
+ keys = Widget.available.join(',')
58
+ "invalid widget handle: #{@handle} " \
59
+ "-- must be one of CLI::UI::Widgets.available (#{keys})"
60
+ end
61
+ end
62
+
63
+ class InvalidWidgetArguments < ArgumentError
64
+ def initialize(argstring, pattern)
65
+ super
66
+ @argstring = argstring
67
+ @pattern = pattern
68
+ end
69
+
70
+ def message
71
+ "invalid widget arguments: #{@argstring} " \
72
+ "-- must match pattern: #{@pattern.inspect}"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,27 @@
1
+ require('cli/ui')
2
+
3
+ module CLI
4
+ module UI
5
+ module Widgets
6
+ class Base
7
+ def self.call(argstring)
8
+ new(argstring).render
9
+ end
10
+
11
+ def initialize(argstring)
12
+ pat = self.class.argparse_pattern
13
+ unless (@match_data = pat.match(argstring))
14
+ raise(Widgets::InvalidWidgetArguments.new(argstring, pat))
15
+ end
16
+ @match_data.names.each do |name|
17
+ instance_variable_set(:"@#{name}", @match_data[name])
18
+ end
19
+ end
20
+
21
+ def self.argparse_pattern
22
+ const_get(:ARGPARSE_PATTERN)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,61 @@
1
+ # frozen-string-literal: true
2
+ require('cli/ui')
3
+
4
+ module CLI
5
+ module UI
6
+ module Widgets
7
+ class Status < Widgets::Base
8
+ ARGPARSE_PATTERN = %r{
9
+ \A (?<succeeded> \d+)
10
+ : (?<failed> \d+)
11
+ : (?<working> \d+)
12
+ : (?<pending> \d+) \z
13
+ }x # e.g. "1:23:3:404"
14
+ OPEN = Color::RESET.code + Color::BOLD.code + '[' + Color::RESET.code
15
+ CLOSE = Color::RESET.code + Color::BOLD.code + ']' + Color::RESET.code
16
+ ARROW = Color::RESET.code + Color::GRAY.code + '◂' + Color::RESET.code
17
+ COMMA = Color::RESET.code + Color::GRAY.code + ',' + Color::RESET.code
18
+
19
+ SPINNER_STOPPED = '⠿'
20
+ EMPTY_SET = '∅'
21
+
22
+ def render
23
+ if zero?(@succeeded) && zero?(@failed) && zero?(@working) && zero?(@pending)
24
+ Color::RESET.code + Color::BOLD.code + EMPTY_SET + Color::RESET.code
25
+ else
26
+ # [ 0✓ , 2✗ ◂ 3⠼ ◂ 4⌛︎ ]
27
+ "#{OPEN}#{succeeded_part}#{COMMA}#{failed_part}#{ARROW}#{working_part}#{ARROW}#{pending_part}#{CLOSE}"
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def zero?(num_str)
34
+ num_str == '0'
35
+ end
36
+
37
+ def colorize_if_nonzero(num_str, rune, color)
38
+ color = Color::GRAY if zero?(num_str)
39
+ color.code + num_str + rune
40
+ end
41
+
42
+ def succeeded_part
43
+ colorize_if_nonzero(@succeeded, Glyph::CHECK.char, Color::GREEN)
44
+ end
45
+
46
+ def failed_part
47
+ colorize_if_nonzero(@failed, Glyph::X.char, Color::RED)
48
+ end
49
+
50
+ def working_part
51
+ rune = zero?(@working) ? SPINNER_STOPPED : Spinner.current_rune
52
+ colorize_if_nonzero(@working, rune, Color::BLUE)
53
+ end
54
+
55
+ def pending_part
56
+ colorize_if_nonzero(@pending, Glyph::HOURGLASS.char, Color::WHITE)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end