cli-ui 0.1.2
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 +7 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +17 -0
- data/.travis.yml +5 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/Rakefile +20 -0
- data/bin/console +14 -0
- data/cli-ui.gemspec +27 -0
- data/dev.yml +14 -0
- data/lib/cli/ui.rb +163 -0
- data/lib/cli/ui/ansi.rb +155 -0
- data/lib/cli/ui/box.rb +15 -0
- data/lib/cli/ui/color.rb +79 -0
- data/lib/cli/ui/formatter.rb +178 -0
- data/lib/cli/ui/frame.rb +310 -0
- data/lib/cli/ui/glyph.rb +74 -0
- data/lib/cli/ui/progress.rb +90 -0
- data/lib/cli/ui/prompt.rb +156 -0
- data/lib/cli/ui/prompt/interactive_options.rb +171 -0
- data/lib/cli/ui/spinner.rb +48 -0
- data/lib/cli/ui/spinner/async.rb +40 -0
- data/lib/cli/ui/spinner/spin_group.rb +223 -0
- data/lib/cli/ui/stdout_router.rb +188 -0
- data/lib/cli/ui/terminal.rb +21 -0
- data/lib/cli/ui/version.rb +5 -0
- metadata +117 -0
data/lib/cli/ui/ansi.rb
ADDED
@@ -0,0 +1,155 @@
|
|
1
|
+
require 'cli/ui'
|
2
|
+
|
3
|
+
module CLI
|
4
|
+
module UI
|
5
|
+
module ANSI
|
6
|
+
ESC = "\x1b"
|
7
|
+
|
8
|
+
# ANSI escape sequences (like \x1b[31m) have zero width.
|
9
|
+
# when calculating the padding width, we must exclude them.
|
10
|
+
# This also implements a basic version of utf8 character width calculation like
|
11
|
+
# we could get for real from something like utf8proc.
|
12
|
+
#
|
13
|
+
def self.printing_width(str)
|
14
|
+
zwj = false
|
15
|
+
strip_codes(str).codepoints.reduce(0) do |acc, cp|
|
16
|
+
if zwj
|
17
|
+
zwj = false
|
18
|
+
next acc
|
19
|
+
end
|
20
|
+
case cp
|
21
|
+
when 0x200d # zero-width joiner
|
22
|
+
zwj = true
|
23
|
+
acc
|
24
|
+
else
|
25
|
+
acc + 1
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Strips ANSI codes from a str
|
31
|
+
#
|
32
|
+
# ==== Attributes
|
33
|
+
#
|
34
|
+
# - +str+ - The string from which to strip codes
|
35
|
+
#
|
36
|
+
def self.strip_codes(str)
|
37
|
+
str.gsub(/\x1b\[[\d;]+[A-z]|\r/, '')
|
38
|
+
end
|
39
|
+
|
40
|
+
# Returns an ANSI control sequence
|
41
|
+
#
|
42
|
+
# ==== Attributes
|
43
|
+
#
|
44
|
+
# - +args+ - Argument to pass to the ANSI control sequence
|
45
|
+
# - +cmd+ - ANSI control sequence Command
|
46
|
+
#
|
47
|
+
def self.control(args, cmd)
|
48
|
+
ESC + "[" + args + cmd
|
49
|
+
end
|
50
|
+
|
51
|
+
# https://en.wikipedia.org/wiki/ANSI_escape_code#graphics
|
52
|
+
def self.sgr(params)
|
53
|
+
control(params.to_s, 'm')
|
54
|
+
end
|
55
|
+
|
56
|
+
# Cursor Movement
|
57
|
+
|
58
|
+
# Move the cursor up n lines
|
59
|
+
#
|
60
|
+
# ==== Attributes
|
61
|
+
#
|
62
|
+
# * +n+ - number of lines by which to move the cursor up
|
63
|
+
#
|
64
|
+
def self.cursor_up(n = 1)
|
65
|
+
return '' if n.zero?
|
66
|
+
control(n.to_s, 'A')
|
67
|
+
end
|
68
|
+
|
69
|
+
# Move the cursor down n lines
|
70
|
+
#
|
71
|
+
# ==== Attributes
|
72
|
+
#
|
73
|
+
# * +n+ - number of lines by which to move the cursor down
|
74
|
+
#
|
75
|
+
def self.cursor_down(n = 1)
|
76
|
+
return '' if n.zero?
|
77
|
+
control(n.to_s, 'B')
|
78
|
+
end
|
79
|
+
|
80
|
+
# Move the cursor forward n columns
|
81
|
+
#
|
82
|
+
# ==== Attributes
|
83
|
+
#
|
84
|
+
# * +n+ - number of columns by which to move the cursor forward
|
85
|
+
#
|
86
|
+
def self.cursor_forward(n = 1)
|
87
|
+
return '' if n.zero?
|
88
|
+
control(n.to_s, 'C')
|
89
|
+
end
|
90
|
+
|
91
|
+
# Move the cursor back n columns
|
92
|
+
#
|
93
|
+
# ==== Attributes
|
94
|
+
#
|
95
|
+
# * +n+ - number of columns by which to move the cursor back
|
96
|
+
#
|
97
|
+
def self.cursor_back(n = 1)
|
98
|
+
return '' if n.zero?
|
99
|
+
control(n.to_s, 'D')
|
100
|
+
end
|
101
|
+
|
102
|
+
# Move the cursor to a specific column
|
103
|
+
#
|
104
|
+
# ==== Attributes
|
105
|
+
#
|
106
|
+
# * +n+ - The column to move to
|
107
|
+
#
|
108
|
+
def self.cursor_horizontal_absolute(n = 1)
|
109
|
+
control(n.to_s, 'G')
|
110
|
+
end
|
111
|
+
|
112
|
+
# Show the cursor
|
113
|
+
#
|
114
|
+
def self.show_cursor
|
115
|
+
control('', "?25h")
|
116
|
+
end
|
117
|
+
|
118
|
+
# Hide the cursor
|
119
|
+
#
|
120
|
+
def self.hide_cursor
|
121
|
+
control('', "?25l")
|
122
|
+
end
|
123
|
+
|
124
|
+
# Save the cursor position
|
125
|
+
#
|
126
|
+
def self.cursor_save
|
127
|
+
control('', 's')
|
128
|
+
end
|
129
|
+
|
130
|
+
# Restore the saved cursor position
|
131
|
+
#
|
132
|
+
def self.cursor_restore
|
133
|
+
control('', 'u')
|
134
|
+
end
|
135
|
+
|
136
|
+
# Move to the next line
|
137
|
+
#
|
138
|
+
def self.next_line
|
139
|
+
cursor_down + control('1', 'G')
|
140
|
+
end
|
141
|
+
|
142
|
+
# Move to the previous line
|
143
|
+
#
|
144
|
+
def self.previous_line
|
145
|
+
cursor_up + control('1', 'G')
|
146
|
+
end
|
147
|
+
|
148
|
+
# Move to the end of the line
|
149
|
+
#
|
150
|
+
def self.end_of_line
|
151
|
+
control("\033[", 'C')
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
data/lib/cli/ui/box.rb
ADDED
data/lib/cli/ui/color.rb
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
require 'cli/ui'
|
2
|
+
|
3
|
+
module CLI
|
4
|
+
module UI
|
5
|
+
class Color
|
6
|
+
attr_reader :sgr, :name, :code
|
7
|
+
|
8
|
+
# Creates a new color mapping
|
9
|
+
# Signatures can be found here:
|
10
|
+
# https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
|
11
|
+
#
|
12
|
+
# ==== Attributes
|
13
|
+
#
|
14
|
+
# * +sgr+ - The color signature
|
15
|
+
# * +name+ - The name of the color
|
16
|
+
#
|
17
|
+
def initialize(sgr, name)
|
18
|
+
@sgr = sgr
|
19
|
+
@code = CLI::UI::ANSI.sgr(sgr)
|
20
|
+
@name = name
|
21
|
+
end
|
22
|
+
|
23
|
+
RED = new('31', :red)
|
24
|
+
GREEN = new('32', :green)
|
25
|
+
YELLOW = new('33', :yellow)
|
26
|
+
# default blue is low-contrast against black in some default terminal color scheme
|
27
|
+
BLUE = new('94', :blue) # 9x = high-intensity fg color x
|
28
|
+
MAGENTA = new('35', :magenta)
|
29
|
+
CYAN = new('36', :cyan)
|
30
|
+
RESET = new('0', :reset)
|
31
|
+
BOLD = new('1', :bold)
|
32
|
+
WHITE = new('97', :white)
|
33
|
+
|
34
|
+
MAP = {
|
35
|
+
red: RED,
|
36
|
+
green: GREEN,
|
37
|
+
yellow: YELLOW,
|
38
|
+
blue: BLUE,
|
39
|
+
magenta: MAGENTA,
|
40
|
+
cyan: CYAN,
|
41
|
+
reset: RESET,
|
42
|
+
bold: BOLD,
|
43
|
+
}.freeze
|
44
|
+
|
45
|
+
class InvalidColorName < ArgumentError
|
46
|
+
def initialize(name)
|
47
|
+
@name = name
|
48
|
+
end
|
49
|
+
|
50
|
+
def message
|
51
|
+
keys = Color.available.map(&:inspect).join(',')
|
52
|
+
"invalid color: #{@name.inspect} " \
|
53
|
+
"-- must be one of CLI::UI::Color.available (#{keys})"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Looks up a color code by name
|
58
|
+
#
|
59
|
+
# ==== Raises
|
60
|
+
# Raises a InvalidColorName if the color is not available
|
61
|
+
# You likely need to add it to the +MAP+ or you made a typo
|
62
|
+
#
|
63
|
+
# ==== Returns
|
64
|
+
# Returns a color code
|
65
|
+
#
|
66
|
+
def self.lookup(name)
|
67
|
+
MAP.fetch(name)
|
68
|
+
rescue KeyError
|
69
|
+
raise InvalidColorName, name
|
70
|
+
end
|
71
|
+
|
72
|
+
# All available colors by name
|
73
|
+
#
|
74
|
+
def self.available
|
75
|
+
MAP.keys
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'cli/ui'
|
4
|
+
require 'strscan'
|
5
|
+
|
6
|
+
module CLI
|
7
|
+
module UI
|
8
|
+
class Formatter
|
9
|
+
# Available mappings of formattings
|
10
|
+
# To use any of them, you can use {{<key>:<string>}}
|
11
|
+
# There are presentational (colours and formatters)
|
12
|
+
# and semantic (error, info, command) formatters available
|
13
|
+
#
|
14
|
+
SGR_MAP = {
|
15
|
+
# presentational
|
16
|
+
'red' => '31',
|
17
|
+
'green' => '32',
|
18
|
+
'yellow' => '33',
|
19
|
+
'blue' => '34',
|
20
|
+
'magenta' => '35',
|
21
|
+
'cyan' => '36',
|
22
|
+
'bold' => '1',
|
23
|
+
'italic' => '3',
|
24
|
+
'underline' => '4',
|
25
|
+
'reset' => '0',
|
26
|
+
|
27
|
+
# semantic
|
28
|
+
'error' => '31', # red
|
29
|
+
'success' => '32', # success
|
30
|
+
'warning' => '33', # yellow
|
31
|
+
'info' => '34', # blue
|
32
|
+
'command' => '36', # cyan
|
33
|
+
}.freeze
|
34
|
+
|
35
|
+
BEGIN_EXPR = '{{'
|
36
|
+
END_EXPR = '}}'
|
37
|
+
|
38
|
+
SCAN_FUNCNAME = /\w+:/
|
39
|
+
SCAN_GLYPH = /.}}/
|
40
|
+
SCAN_BODY = /
|
41
|
+
.*?
|
42
|
+
(
|
43
|
+
#{BEGIN_EXPR} |
|
44
|
+
#{END_EXPR} |
|
45
|
+
\z
|
46
|
+
)
|
47
|
+
/mx
|
48
|
+
|
49
|
+
DISCARD_BRACES = 0..-3
|
50
|
+
|
51
|
+
LITERAL_BRACES = :__literal_braces__
|
52
|
+
|
53
|
+
class FormatError < StandardError
|
54
|
+
attr_accessor :input, :index
|
55
|
+
|
56
|
+
def initialize(message = nil, input = nil, index = nil)
|
57
|
+
super(message)
|
58
|
+
@input = input
|
59
|
+
@index = index
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Initialize a formatter with text.
|
64
|
+
#
|
65
|
+
# ===== Attributes
|
66
|
+
#
|
67
|
+
# * +text+ - the text to format
|
68
|
+
#
|
69
|
+
def initialize(text)
|
70
|
+
@text = text
|
71
|
+
end
|
72
|
+
|
73
|
+
# Format the text using a map.
|
74
|
+
#
|
75
|
+
# ===== Attributes
|
76
|
+
#
|
77
|
+
# * +sgr_map+ - the mapping of the formattings. Defaults to +SGR_MAP+
|
78
|
+
#
|
79
|
+
# ===== Options
|
80
|
+
#
|
81
|
+
# * +:enable_color+ - enable color output? Default is true
|
82
|
+
#
|
83
|
+
def format(sgr_map = SGR_MAP, enable_color: true)
|
84
|
+
@nodes = []
|
85
|
+
stack = parse_body(StringScanner.new(@text))
|
86
|
+
prev_fmt = nil
|
87
|
+
content = @nodes.each_with_object(String.new) do |(text, fmt), str|
|
88
|
+
if prev_fmt != fmt && enable_color
|
89
|
+
text = apply_format(text, fmt, sgr_map)
|
90
|
+
end
|
91
|
+
str << text
|
92
|
+
prev_fmt = fmt
|
93
|
+
end
|
94
|
+
|
95
|
+
stack.reject! { |e| e == LITERAL_BRACES }
|
96
|
+
|
97
|
+
return content unless enable_color
|
98
|
+
return content if stack == prev_fmt
|
99
|
+
|
100
|
+
unless stack.empty? && (@nodes.size.zero? || @nodes.last[1].empty?)
|
101
|
+
content << apply_format('', stack, sgr_map)
|
102
|
+
end
|
103
|
+
content
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
def apply_format(text, fmt, sgr_map)
|
109
|
+
sgr = fmt.each_with_object(String.new('0')) do |name, str|
|
110
|
+
next if name == LITERAL_BRACES
|
111
|
+
begin
|
112
|
+
str << ';' << sgr_map.fetch(name)
|
113
|
+
rescue KeyError
|
114
|
+
raise FormatError.new(
|
115
|
+
"invalid format specifier: #{name}",
|
116
|
+
@text,
|
117
|
+
-1
|
118
|
+
)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
CLI::UI::ANSI.sgr(sgr) + text
|
122
|
+
end
|
123
|
+
|
124
|
+
def parse_expr(sc, stack)
|
125
|
+
if match = sc.scan(SCAN_GLYPH)
|
126
|
+
glyph_handle = match[0]
|
127
|
+
begin
|
128
|
+
glyph = Glyph.lookup(glyph_handle)
|
129
|
+
emit(glyph.char, [glyph.color.name.to_s])
|
130
|
+
rescue Glyph::InvalidGlyphHandle
|
131
|
+
index = sc.pos - 2 # rewind past '}}'
|
132
|
+
raise FormatError.new(
|
133
|
+
"invalid glyph handle at index #{index}: '#{glyph_handle}'",
|
134
|
+
@text,
|
135
|
+
index
|
136
|
+
)
|
137
|
+
end
|
138
|
+
elsif match = sc.scan(SCAN_FUNCNAME)
|
139
|
+
funcname = match.chop
|
140
|
+
stack.push(funcname)
|
141
|
+
else
|
142
|
+
# We read a {{ but it's not apparently Formatter syntax.
|
143
|
+
# We could error, but it's nicer to just pass through as text.
|
144
|
+
# We do kind of assume that the text will probably have balanced
|
145
|
+
# pairs of {{ }} at least.
|
146
|
+
emit('{{', stack)
|
147
|
+
stack.push(LITERAL_BRACES)
|
148
|
+
end
|
149
|
+
parse_body(sc, stack)
|
150
|
+
stack
|
151
|
+
end
|
152
|
+
|
153
|
+
def parse_body(sc, stack = [])
|
154
|
+
match = sc.scan(SCAN_BODY)
|
155
|
+
if match && match.end_with?(BEGIN_EXPR)
|
156
|
+
emit(match[DISCARD_BRACES], stack)
|
157
|
+
parse_expr(sc, stack)
|
158
|
+
elsif match && match.end_with?(END_EXPR)
|
159
|
+
emit(match[DISCARD_BRACES], stack)
|
160
|
+
if stack.pop == LITERAL_BRACES
|
161
|
+
emit('}}', stack)
|
162
|
+
end
|
163
|
+
parse_body(sc, stack)
|
164
|
+
elsif match
|
165
|
+
emit(match, stack)
|
166
|
+
else
|
167
|
+
emit(sc.rest, stack)
|
168
|
+
end
|
169
|
+
stack
|
170
|
+
end
|
171
|
+
|
172
|
+
def emit(text, stack)
|
173
|
+
return if text.nil? || text.empty?
|
174
|
+
@nodes << [text, stack.reject { |n| n == LITERAL_BRACES }]
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
data/lib/cli/ui/frame.rb
ADDED
@@ -0,0 +1,310 @@
|
|
1
|
+
require 'cli/ui'
|
2
|
+
|
3
|
+
module CLI
|
4
|
+
module UI
|
5
|
+
module Frame
|
6
|
+
class UnnestedFrameException < StandardError; end
|
7
|
+
class << self
|
8
|
+
DEFAULT_FRAME_COLOR = CLI::UI.resolve_color(:cyan)
|
9
|
+
|
10
|
+
# Opens a new frame. Can be nested
|
11
|
+
# Can be invoked in two ways: block and blockless
|
12
|
+
# * In block form, the frame is closed automatically when the block returns
|
13
|
+
# * In blockless form, caller MUST call +Frame.close+ when the frame is logically done
|
14
|
+
# * Blockless form is strongly discouraged in cases where block form can be made to work
|
15
|
+
#
|
16
|
+
# https://user-images.githubusercontent.com/3074765/33799861-cb5dcb5c-dd01-11e7-977e-6fad38cee08c.png
|
17
|
+
#
|
18
|
+
# The return value of the block determines if the block is a "success" or a "failure"
|
19
|
+
#
|
20
|
+
# ==== Attributes
|
21
|
+
#
|
22
|
+
# * +text+ - (required) the text/title to output in the frame
|
23
|
+
#
|
24
|
+
# ==== Options
|
25
|
+
#
|
26
|
+
# * +:color+ - The color of the frame. Defaults to +DEFAULT_FRAME_COLOR+
|
27
|
+
# * +:failure_text+ - If the block failed, what do we output? Defaults to nil
|
28
|
+
# * +:success_text+ - If the block succeeds, what do we output? Defaults to nil
|
29
|
+
# * +:timing+ - How long did the frame content take? Invalid for blockless. Defaults to true for the block form
|
30
|
+
#
|
31
|
+
# ==== Example
|
32
|
+
#
|
33
|
+
# ===== Block Form (Assumes +CLI::UI::StdoutRouter.enable+ has been called)
|
34
|
+
#
|
35
|
+
# CLI::UI::Frame.open('Open') { puts 'hi' }
|
36
|
+
#
|
37
|
+
# Output:
|
38
|
+
# ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
39
|
+
# ┃ hi
|
40
|
+
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (0.0s) ━━
|
41
|
+
#
|
42
|
+
# ===== Blockless Form
|
43
|
+
#
|
44
|
+
# CLI::UI::Frame.open('Open')
|
45
|
+
#
|
46
|
+
# Output:
|
47
|
+
# ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
48
|
+
#
|
49
|
+
#
|
50
|
+
def open(
|
51
|
+
text,
|
52
|
+
color: DEFAULT_FRAME_COLOR,
|
53
|
+
failure_text: nil,
|
54
|
+
success_text: nil,
|
55
|
+
timing: nil
|
56
|
+
)
|
57
|
+
color = CLI::UI.resolve_color(color)
|
58
|
+
|
59
|
+
unless block_given?
|
60
|
+
if failure_text
|
61
|
+
raise ArgumentError, "failure_text is not compatible with blockless invocation"
|
62
|
+
elsif success_text
|
63
|
+
raise ArgumentError, "success_text is not compatible with blockless invocation"
|
64
|
+
elsif !timing.nil?
|
65
|
+
raise ArgumentError, "timing is not compatible with blockless invocation"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
timing = true if timing.nil?
|
70
|
+
|
71
|
+
t_start = Time.now.to_f
|
72
|
+
CLI::UI.raw do
|
73
|
+
puts edge(text, color: color, first: CLI::UI::Box::Heavy::TL)
|
74
|
+
end
|
75
|
+
FrameStack.push(color)
|
76
|
+
|
77
|
+
return unless block_given?
|
78
|
+
|
79
|
+
closed = false
|
80
|
+
begin
|
81
|
+
success = false
|
82
|
+
success = yield
|
83
|
+
rescue
|
84
|
+
closed = true
|
85
|
+
t_diff = timing ? (Time.now.to_f - t_start) : nil
|
86
|
+
close(failure_text, color: :red, elapsed: t_diff)
|
87
|
+
raise
|
88
|
+
else
|
89
|
+
success
|
90
|
+
ensure
|
91
|
+
unless closed
|
92
|
+
t_diff = timing ? (Time.now.to_f - t_start) : nil
|
93
|
+
if success != false
|
94
|
+
close(success_text, color: color, elapsed: t_diff)
|
95
|
+
else
|
96
|
+
close(failure_text, color: :red, elapsed: t_diff)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Closes a frame
|
103
|
+
# Automatically called for a block-form +open+
|
104
|
+
#
|
105
|
+
# ==== Attributes
|
106
|
+
#
|
107
|
+
# * +text+ - (required) the text/title to output in the frame
|
108
|
+
#
|
109
|
+
# ==== Options
|
110
|
+
#
|
111
|
+
# * +:color+ - The color of the frame. Defaults to +DEFAULT_FRAME_COLOR+
|
112
|
+
# * +:elapsed+ - How long did the frame take? Defaults to nil
|
113
|
+
#
|
114
|
+
# ==== Example
|
115
|
+
#
|
116
|
+
# CLI::UI::Frame.close('Close')
|
117
|
+
#
|
118
|
+
# Output:
|
119
|
+
# ┗━━ Close ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
120
|
+
#
|
121
|
+
#
|
122
|
+
def close(text, color: DEFAULT_FRAME_COLOR, elapsed: nil)
|
123
|
+
color = CLI::UI.resolve_color(color)
|
124
|
+
|
125
|
+
FrameStack.pop
|
126
|
+
kwargs = {}
|
127
|
+
if elapsed
|
128
|
+
kwargs[:right_text] = "(#{elapsed.round(2)}s)"
|
129
|
+
end
|
130
|
+
CLI::UI.raw do
|
131
|
+
puts edge(text, color: color, first: CLI::UI::Box::Heavy::BL, **kwargs)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
# Adds a divider in a frame
|
136
|
+
# Used to separate information within a single frame
|
137
|
+
#
|
138
|
+
# ==== Attributes
|
139
|
+
#
|
140
|
+
# * +text+ - (required) the text/title to output in the frame
|
141
|
+
#
|
142
|
+
# ==== Options
|
143
|
+
#
|
144
|
+
# * +:color+ - The color of the frame. Defaults to +DEFAULT_FRAME_COLOR+
|
145
|
+
#
|
146
|
+
# ==== Example
|
147
|
+
#
|
148
|
+
# CLI::UI::Frame.open('Open') { CLI::UI::Frame.divider('Divider') }
|
149
|
+
#
|
150
|
+
# Output:
|
151
|
+
# ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
152
|
+
# ┣━━ Divider ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
153
|
+
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
154
|
+
#
|
155
|
+
# ==== Raises
|
156
|
+
#
|
157
|
+
# MUST be inside an open frame or it raises a +UnnestedFrameException+
|
158
|
+
#
|
159
|
+
def divider(text, color: nil)
|
160
|
+
fs_item = FrameStack.pop
|
161
|
+
raise UnnestedFrameException, "no frame nesting to unnest" unless fs_item
|
162
|
+
color = CLI::UI.resolve_color(color)
|
163
|
+
item = CLI::UI.resolve_color(fs_item)
|
164
|
+
|
165
|
+
CLI::UI.raw do
|
166
|
+
puts edge(text, color: (color || item), first: CLI::UI::Box::Heavy::DIV)
|
167
|
+
end
|
168
|
+
FrameStack.push(item)
|
169
|
+
end
|
170
|
+
|
171
|
+
# Determines the prefix of a frame entry taking multi-nested frames into account
|
172
|
+
#
|
173
|
+
# ==== Options
|
174
|
+
#
|
175
|
+
# * +:color+ - The color of the prefix. Defaults to +Thread.current[:cliui_frame_color_override]+ or nil
|
176
|
+
#
|
177
|
+
def prefix(color: nil)
|
178
|
+
pfx = String.new
|
179
|
+
items = FrameStack.items
|
180
|
+
items[0..-2].each do |item|
|
181
|
+
pfx << CLI::UI.resolve_color(item).code << CLI::UI::Box::Heavy::VERT
|
182
|
+
end
|
183
|
+
if item = items.last
|
184
|
+
c = Thread.current[:cliui_frame_color_override] || color || item
|
185
|
+
pfx << CLI::UI.resolve_color(c).code \
|
186
|
+
<< CLI::UI::Box::Heavy::VERT << ' ' << CLI::UI::Color::RESET.code
|
187
|
+
end
|
188
|
+
pfx
|
189
|
+
end
|
190
|
+
|
191
|
+
# Override a color for a given thread.
|
192
|
+
#
|
193
|
+
# ==== Attributes
|
194
|
+
#
|
195
|
+
# * +color+ - The color to override to
|
196
|
+
#
|
197
|
+
def with_frame_color_override(color)
|
198
|
+
prev = Thread.current[:cliui_frame_color_override]
|
199
|
+
Thread.current[:cliui_frame_color_override] = color
|
200
|
+
yield
|
201
|
+
ensure
|
202
|
+
Thread.current[:cliui_frame_color_override] = prev
|
203
|
+
end
|
204
|
+
|
205
|
+
# The width of a prefix given the number of Frames in the stack
|
206
|
+
#
|
207
|
+
def prefix_width
|
208
|
+
w = FrameStack.items.size
|
209
|
+
w.zero? ? 0 : w + 1
|
210
|
+
end
|
211
|
+
|
212
|
+
private
|
213
|
+
|
214
|
+
def edge(text, color: raise, first: raise, right_text: nil)
|
215
|
+
color = CLI::UI.resolve_color(color)
|
216
|
+
text = CLI::UI.resolve_text("{{#{color.name}:#{text}}}")
|
217
|
+
|
218
|
+
prefix = String.new
|
219
|
+
FrameStack.items.each do |item|
|
220
|
+
prefix << CLI::UI.resolve_color(item).code << CLI::UI::Box::Heavy::VERT
|
221
|
+
end
|
222
|
+
prefix << color.code << first << (CLI::UI::Box::Heavy::HORZ * 2)
|
223
|
+
text ||= ''
|
224
|
+
unless text.empty?
|
225
|
+
prefix << ' ' << text << ' '
|
226
|
+
end
|
227
|
+
|
228
|
+
termwidth = CLI::UI::Terminal.width
|
229
|
+
|
230
|
+
suffix = String.new
|
231
|
+
if right_text
|
232
|
+
suffix << ' ' << right_text << ' '
|
233
|
+
end
|
234
|
+
|
235
|
+
suffix_width = CLI::UI::ANSI.printing_width(suffix)
|
236
|
+
suffix_end = termwidth - 2
|
237
|
+
suffix_start = suffix_end - suffix_width
|
238
|
+
|
239
|
+
prefix_width = CLI::UI::ANSI.printing_width(prefix)
|
240
|
+
prefix_start = 0
|
241
|
+
prefix_end = prefix_start + prefix_width
|
242
|
+
|
243
|
+
if prefix_end > suffix_start
|
244
|
+
suffix = ''
|
245
|
+
# if prefix_end > termwidth
|
246
|
+
# we *could* truncate it, but let's just let it overflow to the
|
247
|
+
# next line and call it poor usage of this API.
|
248
|
+
end
|
249
|
+
|
250
|
+
o = String.new
|
251
|
+
|
252
|
+
is_ci = ![0, '', nil].include?(ENV['CI'])
|
253
|
+
|
254
|
+
# Jumping around the line can cause some unwanted flashes
|
255
|
+
o << CLI::UI::ANSI.hide_cursor
|
256
|
+
|
257
|
+
o << if is_ci
|
258
|
+
# In CI, we can't use absolute horizontal positions because of timestamps.
|
259
|
+
# So we move around the line by offset from this cursor position.
|
260
|
+
CLI::UI::ANSI.cursor_save
|
261
|
+
else
|
262
|
+
# Outside of CI, we reset to column 1 so that things like ^C don't
|
263
|
+
# cause output misformatting.
|
264
|
+
"\r"
|
265
|
+
end
|
266
|
+
|
267
|
+
o << color.code
|
268
|
+
o << CLI::UI::Box::Heavy::HORZ * termwidth # draw a full line
|
269
|
+
o << print_at_x(prefix_start, prefix, is_ci)
|
270
|
+
o << color.code
|
271
|
+
o << print_at_x(suffix_start, suffix, is_ci)
|
272
|
+
o << CLI::UI::Color::RESET.code
|
273
|
+
o << CLI::UI::ANSI.show_cursor
|
274
|
+
o << "\n"
|
275
|
+
|
276
|
+
o
|
277
|
+
end
|
278
|
+
|
279
|
+
def print_at_x(x, str, is_ci)
|
280
|
+
if is_ci
|
281
|
+
CLI::UI::ANSI.cursor_restore + CLI::UI::ANSI.cursor_forward(x) + str
|
282
|
+
else
|
283
|
+
CLI::UI::ANSI.cursor_horizontal_absolute(1 + x) + str
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
module FrameStack
|
288
|
+
ENVVAR = 'CLI_FRAME_STACK'
|
289
|
+
|
290
|
+
def self.items
|
291
|
+
ENV.fetch(ENVVAR, '').split(':').map(&:to_sym)
|
292
|
+
end
|
293
|
+
|
294
|
+
def self.push(item)
|
295
|
+
curr = items
|
296
|
+
curr << item.name
|
297
|
+
ENV[ENVVAR] = curr.join(':')
|
298
|
+
end
|
299
|
+
|
300
|
+
def self.pop
|
301
|
+
curr = items
|
302
|
+
ret = curr.pop
|
303
|
+
ENV[ENVVAR] = curr.join(':')
|
304
|
+
ret.nil? ? nil : ret.to_sym
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|