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.
- checksums.yaml +4 -4
- data/README.md +45 -1
- data/lib/cli/ui.rb +75 -29
- data/lib/cli/ui/ansi.rb +10 -6
- data/lib/cli/ui/color.rb +12 -7
- data/lib/cli/ui/formatter.rb +34 -21
- data/lib/cli/ui/frame.rb +111 -152
- data/lib/cli/ui/frame/frame_stack.rb +98 -0
- data/lib/cli/ui/frame/frame_style.rb +120 -0
- data/lib/cli/ui/frame/frame_style/box.rb +166 -0
- data/lib/cli/ui/frame/frame_style/bracket.rb +139 -0
- data/lib/cli/ui/glyph.rb +23 -17
- data/lib/cli/ui/os.rb +67 -0
- data/lib/cli/ui/printer.rb +59 -0
- data/lib/cli/ui/progress.rb +9 -7
- data/lib/cli/ui/prompt.rb +97 -21
- data/lib/cli/ui/prompt/interactive_options.rb +75 -61
- data/lib/cli/ui/prompt/options_handler.rb +7 -2
- data/lib/cli/ui/spinner.rb +23 -5
- data/lib/cli/ui/spinner/spin_group.rb +34 -12
- data/lib/cli/ui/stdout_router.rb +13 -8
- data/lib/cli/ui/terminal.rb +26 -16
- data/lib/cli/ui/truncater.rb +4 -4
- data/lib/cli/ui/version.rb +1 -1
- data/lib/cli/ui/widgets.rb +77 -0
- data/lib/cli/ui/widgets/base.rb +27 -0
- data/lib/cli/ui/widgets/status.rb +61 -0
- data/lib/cli/ui/wrap.rb +56 -0
- metadata +17 -16
- data/.gitignore +0 -15
- data/.rubocop.yml +0 -17
- data/.travis.yml +0 -5
- data/Gemfile +0 -16
- data/Rakefile +0 -20
- data/bin/console +0 -14
- data/cli-ui.gemspec +0 -27
- data/dev.yml +0 -14
- data/lib/cli/ui/box.rb +0 -15
@@ -15,8 +15,13 @@ module CLI
|
|
15
15
|
@options[option] = handler
|
16
16
|
end
|
17
17
|
|
18
|
-
def call(
|
19
|
-
|
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
|
data/lib/cli/ui/spinner.rb
CHANGED
@@ -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
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
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
|
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
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
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
|
-
@
|
101
|
-
|
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
|
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 =
|
249
|
+
out = '(empty)' if out.nil? || out.strip.empty?
|
228
250
|
puts out
|
229
251
|
|
230
252
|
CLI::UI::Frame.divider('STDERR')
|
231
|
-
err =
|
253
|
+
err = '(empty)' if err.nil? || err.strip.empty?
|
232
254
|
puts err
|
233
255
|
end
|
234
256
|
end
|
data/lib/cli/ui/stdout_router.rb
CHANGED
@@ -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
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
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(
|
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
|
data/lib/cli/ui/terminal.rb
CHANGED
@@ -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
|
11
|
+
# Otherwise will return DEFAULT_WIDTH
|
12
12
|
#
|
13
13
|
def self.width
|
14
|
-
|
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
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
data/lib/cli/ui/truncater.rb
CHANGED
@@ -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
|
-
|
57
|
+
PARSE_ANSI
|
58
58
|
else
|
59
|
-
|
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(
|
86
|
+
codepoints[0...truncation_index].pack('U*') + TRUNCATED
|
87
87
|
end
|
88
88
|
|
89
89
|
private
|
data/lib/cli/ui/version.rb
CHANGED
@@ -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
|