cli-ui 1.2.2 → 1.5.1

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