dev-ui 0.0.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 +7 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +23 -0
- data/.travis.yml +5 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +47 -0
- data/LICENSE.txt +21 -0
- data/README.md +20 -0
- data/bin/console +14 -0
- data/bin/testunit +20 -0
- data/dev-ui.gemspec +27 -0
- data/dev.yml +8 -0
- data/lib/dev/ui.rb +85 -0
- data/lib/dev/ui/ansi.rb +74 -0
- data/lib/dev/ui/box.rb +15 -0
- data/lib/dev/ui/color.rb +58 -0
- data/lib/dev/ui/formatter.rb +155 -0
- data/lib/dev/ui/frame.rb +176 -0
- data/lib/dev/ui/glyph.rb +49 -0
- data/lib/dev/ui/interactive_prompt.rb +114 -0
- data/lib/dev/ui/progress.rb +65 -0
- data/lib/dev/ui/prompt.rb +88 -0
- data/lib/dev/ui/spinner.rb +168 -0
- data/lib/dev/ui/stdout_router.rb +189 -0
- data/lib/dev/ui/terminal.rb +18 -0
- data/lib/dev/ui/version.rb +5 -0
- metadata +111 -0
@@ -0,0 +1,155 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dev/ui'
|
4
|
+
require 'strscan'
|
5
|
+
|
6
|
+
module Dev
|
7
|
+
module UI
|
8
|
+
class Formatter
|
9
|
+
SGR_MAP = {
|
10
|
+
# presentational
|
11
|
+
'red' => '31',
|
12
|
+
'green' => '32',
|
13
|
+
'yellow' => '33',
|
14
|
+
'blue' => '34',
|
15
|
+
'magenta' => '35',
|
16
|
+
'cyan' => '36',
|
17
|
+
'bold' => '1',
|
18
|
+
'reset' => '0',
|
19
|
+
|
20
|
+
# semantic
|
21
|
+
'error' => '31', # red
|
22
|
+
'success' => '32', # success
|
23
|
+
'warning' => '33', # yellow
|
24
|
+
'info' => '34', # blue
|
25
|
+
'command' => '36', # cyan
|
26
|
+
}.freeze
|
27
|
+
|
28
|
+
BEGIN_EXPR = '{{'
|
29
|
+
END_EXPR = '}}'
|
30
|
+
|
31
|
+
SCAN_FUNCNAME = /\w+:/
|
32
|
+
SCAN_GLYPH = /.}}/
|
33
|
+
SCAN_BODY = /
|
34
|
+
.*?
|
35
|
+
(
|
36
|
+
#{BEGIN_EXPR} |
|
37
|
+
#{END_EXPR} |
|
38
|
+
\z
|
39
|
+
)
|
40
|
+
/mx
|
41
|
+
|
42
|
+
DISCARD_BRACES = 0..-3
|
43
|
+
|
44
|
+
LITERAL_BRACES = :__literal_braces__
|
45
|
+
|
46
|
+
class FormatError < StandardError
|
47
|
+
attr_accessor :input, :index
|
48
|
+
|
49
|
+
def initialize(message = nil, input = nil, index = nil)
|
50
|
+
super(message)
|
51
|
+
@input = input
|
52
|
+
@index = index
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def initialize(text)
|
57
|
+
@text = text
|
58
|
+
end
|
59
|
+
|
60
|
+
def format(sgr_map = SGR_MAP, enable_color: true)
|
61
|
+
@nodes = []
|
62
|
+
stack = parse_body(StringScanner.new(@text))
|
63
|
+
prev_fmt = nil
|
64
|
+
content = @nodes.each_with_object(String.new) do |(text, fmt), str|
|
65
|
+
if prev_fmt != fmt && enable_color
|
66
|
+
text = apply_format(text, fmt, sgr_map)
|
67
|
+
end
|
68
|
+
str << text
|
69
|
+
prev_fmt = fmt
|
70
|
+
end
|
71
|
+
|
72
|
+
stack.reject! { |e| e == LITERAL_BRACES }
|
73
|
+
|
74
|
+
return content unless enable_color
|
75
|
+
return content if stack == prev_fmt
|
76
|
+
|
77
|
+
unless stack.empty? && (@nodes.size.zero? || @nodes.last[1].empty?)
|
78
|
+
content << apply_format('', stack, sgr_map)
|
79
|
+
end
|
80
|
+
content
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def apply_format(text, fmt, sgr_map)
|
86
|
+
sgr = fmt.each_with_object(String.new('0')) do |name, str|
|
87
|
+
next if name == LITERAL_BRACES
|
88
|
+
begin
|
89
|
+
str << ';' << sgr_map.fetch(name)
|
90
|
+
rescue KeyError
|
91
|
+
raise FormatError.new(
|
92
|
+
"invalid format specifier: #{name}",
|
93
|
+
@text,
|
94
|
+
-1
|
95
|
+
)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
Dev::UI::ANSI.sgr(sgr) + text
|
99
|
+
end
|
100
|
+
|
101
|
+
def parse_expr(sc, stack)
|
102
|
+
if match = sc.scan(SCAN_GLYPH)
|
103
|
+
glyph_handle = match[0]
|
104
|
+
begin
|
105
|
+
glyph = Glyph.lookup(glyph_handle)
|
106
|
+
emit(glyph.char, [glyph.color.name.to_s])
|
107
|
+
rescue Glyph::InvalidGlyphHandle
|
108
|
+
index = sc.pos - 2 # rewind past '}}'
|
109
|
+
raise FormatError.new(
|
110
|
+
"invalid glyph handle at index #{index}: '#{glyph_handle}'",
|
111
|
+
@text,
|
112
|
+
index
|
113
|
+
)
|
114
|
+
end
|
115
|
+
elsif match = sc.scan(SCAN_FUNCNAME)
|
116
|
+
funcname = match.chop
|
117
|
+
stack.push(funcname)
|
118
|
+
else
|
119
|
+
# We read a {{ but it's not apparently Formatter syntax.
|
120
|
+
# We could error, but it's nicer to just pass through as text.
|
121
|
+
# We do kind of assume that the text will probably have balanced
|
122
|
+
# pairs of {{ }} at least.
|
123
|
+
emit('{{', stack)
|
124
|
+
stack.push(LITERAL_BRACES)
|
125
|
+
end
|
126
|
+
parse_body(sc, stack)
|
127
|
+
stack
|
128
|
+
end
|
129
|
+
|
130
|
+
def parse_body(sc, stack = [])
|
131
|
+
match = sc.scan(SCAN_BODY)
|
132
|
+
if match && match.end_with?(BEGIN_EXPR)
|
133
|
+
emit(match[DISCARD_BRACES], stack)
|
134
|
+
parse_expr(sc, stack)
|
135
|
+
elsif match && match.end_with?(END_EXPR)
|
136
|
+
emit(match[DISCARD_BRACES], stack)
|
137
|
+
if stack.pop == LITERAL_BRACES
|
138
|
+
emit('}}', stack)
|
139
|
+
end
|
140
|
+
parse_body(sc, stack)
|
141
|
+
elsif match
|
142
|
+
emit(match, stack)
|
143
|
+
else
|
144
|
+
emit(sc.rest, stack)
|
145
|
+
end
|
146
|
+
stack
|
147
|
+
end
|
148
|
+
|
149
|
+
def emit(text, stack)
|
150
|
+
return if text.nil? || text.empty?
|
151
|
+
@nodes << [text, stack.reject { |n| n == LITERAL_BRACES }]
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
data/lib/dev/ui/frame.rb
ADDED
@@ -0,0 +1,176 @@
|
|
1
|
+
require 'dev/ui'
|
2
|
+
|
3
|
+
module Dev
|
4
|
+
module UI
|
5
|
+
module Frame
|
6
|
+
class << self
|
7
|
+
DEFAULT_FRAME_COLOR = Dev::UI.resolve_color(:cyan)
|
8
|
+
|
9
|
+
# Can be invoked in two ways: block and blockless
|
10
|
+
# In block form, the frame is closed automatically when the block returns
|
11
|
+
# In blockless form, caller MUST call Frame.close when the frame is
|
12
|
+
# logically done.
|
13
|
+
# blockless form is strongly discouraged in cases where block form can be
|
14
|
+
# made to work.
|
15
|
+
def open(
|
16
|
+
text,
|
17
|
+
color: DEFAULT_FRAME_COLOR,
|
18
|
+
failure_text: nil,
|
19
|
+
success_text: nil,
|
20
|
+
timing: nil
|
21
|
+
)
|
22
|
+
color = Dev::UI.resolve_color(color)
|
23
|
+
|
24
|
+
unless block_given?
|
25
|
+
if failure_text
|
26
|
+
raise ArgumentError, "failure_text is not compatible with blockless invocation"
|
27
|
+
elsif success_text
|
28
|
+
raise ArgumentError, "success_text is not compatible with blockless invocation"
|
29
|
+
elsif !timing.nil?
|
30
|
+
raise ArgumentError, "timing is not compatible with blockless invocation"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
timing = true if timing.nil?
|
35
|
+
|
36
|
+
t_start = Time.now.to_f
|
37
|
+
Dev::UI.raw do
|
38
|
+
puts edge(text, color: color, first: Dev::UI::Box::Heavy::TL)
|
39
|
+
end
|
40
|
+
FrameStack.push(color)
|
41
|
+
|
42
|
+
return unless block_given?
|
43
|
+
|
44
|
+
closed = false
|
45
|
+
begin
|
46
|
+
success = false
|
47
|
+
success = yield
|
48
|
+
rescue Exception
|
49
|
+
closed = true
|
50
|
+
t_diff = timing ? (Time.now.to_f - t_start) : nil
|
51
|
+
close(failure_text, color: :red, elapsed: t_diff)
|
52
|
+
raise
|
53
|
+
else
|
54
|
+
success
|
55
|
+
ensure
|
56
|
+
unless closed
|
57
|
+
t_diff = timing ? (Time.now.to_f - t_start) : nil
|
58
|
+
if success != false
|
59
|
+
close(success_text, color: color, elapsed: t_diff)
|
60
|
+
else
|
61
|
+
close(failure_text, color: :red, elapsed: t_diff)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def close(text, color: DEFAULT_FRAME_COLOR, elapsed: nil)
|
68
|
+
color = Dev::UI.resolve_color(color)
|
69
|
+
|
70
|
+
FrameStack.pop
|
71
|
+
kwargs = {}
|
72
|
+
if elapsed
|
73
|
+
kwargs[:right_text] = "(#{elapsed.round(2)}s)"
|
74
|
+
end
|
75
|
+
Dev::UI.raw do
|
76
|
+
puts edge(text, color: color, first: Dev::UI::Box::Heavy::BL, **kwargs)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def divider(text, color: nil)
|
81
|
+
color = Dev::UI.resolve_color(color)
|
82
|
+
item = Dev::UI.resolve_color(FrameStack.pop)
|
83
|
+
|
84
|
+
Dev::UI.raw do
|
85
|
+
puts edge(text, color: (color || item), first: Dev::UI::Box::Heavy::DIV)
|
86
|
+
end
|
87
|
+
FrameStack.push(item)
|
88
|
+
end
|
89
|
+
|
90
|
+
def prefix(color: nil)
|
91
|
+
pfx = String.new
|
92
|
+
items = FrameStack.items
|
93
|
+
items[0..-2].each do |item|
|
94
|
+
pfx << Dev::UI.resolve_color(item).code << Dev::UI::Box::Heavy::VERT
|
95
|
+
end
|
96
|
+
if item = items.last
|
97
|
+
c = Thread.current[:devui_frame_color_override] || color || item
|
98
|
+
pfx << Dev::UI.resolve_color(c).code \
|
99
|
+
<< Dev::UI::Box::Heavy::VERT << ' ' << Dev::UI::Color::RESET.code
|
100
|
+
end
|
101
|
+
pfx
|
102
|
+
end
|
103
|
+
|
104
|
+
def with_frame_color_override(color)
|
105
|
+
prev = Thread.current[:devui_frame_color_override]
|
106
|
+
Thread.current[:devui_frame_color_override] = color
|
107
|
+
yield
|
108
|
+
ensure
|
109
|
+
Thread.current[:devui_frame_color_override] = prev
|
110
|
+
end
|
111
|
+
|
112
|
+
def prefix_width
|
113
|
+
w = FrameStack.items.size
|
114
|
+
w.zero? ? 0 : w + 1
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def edge(text, color: raise, first: raise, right_text: nil)
|
120
|
+
color = Dev::UI.resolve_color(color)
|
121
|
+
text = Dev::UI.resolve_text("{{#{color.name}:#{text}}}")
|
122
|
+
|
123
|
+
prefix = String.new
|
124
|
+
FrameStack.items.each do |item|
|
125
|
+
prefix << Dev::UI.resolve_color(item).code << Dev::UI::Box::Heavy::VERT
|
126
|
+
end
|
127
|
+
prefix << color.code << first << (Dev::UI::Box::Heavy::HORZ * 2)
|
128
|
+
text ||= ''
|
129
|
+
unless text.empty?
|
130
|
+
prefix << ' ' << text << ' '
|
131
|
+
end
|
132
|
+
|
133
|
+
suffix = String.new
|
134
|
+
if right_text
|
135
|
+
suffix << ' ' << right_text << ' ' << color.code << (Dev::UI::Box::Heavy::HORZ * 2)
|
136
|
+
end
|
137
|
+
|
138
|
+
textwidth = Dev::UI::ANSI.printing_width(prefix + suffix)
|
139
|
+
termwidth = Dev::UI::Terminal.width
|
140
|
+
termwidth = 30 if termwidth < 30
|
141
|
+
|
142
|
+
if textwidth > termwidth
|
143
|
+
suffix = ''
|
144
|
+
prefix = prefix[0...termwidth]
|
145
|
+
textwidth = termwidth
|
146
|
+
end
|
147
|
+
padwidth = termwidth - textwidth
|
148
|
+
pad = Dev::UI::Box::Heavy::HORZ * padwidth
|
149
|
+
|
150
|
+
prefix + color.code + pad + suffix + Dev::UI::Color::RESET.code + "\n"
|
151
|
+
end
|
152
|
+
|
153
|
+
module FrameStack
|
154
|
+
ENVVAR = 'DEV_FRAME_STACK'
|
155
|
+
|
156
|
+
def self.items
|
157
|
+
ENV.fetch(ENVVAR, '').split(':').map(&:to_sym)
|
158
|
+
end
|
159
|
+
|
160
|
+
def self.push(item)
|
161
|
+
curr = items
|
162
|
+
curr << item.name
|
163
|
+
ENV[ENVVAR] = curr.join(':')
|
164
|
+
end
|
165
|
+
|
166
|
+
def self.pop
|
167
|
+
curr = items
|
168
|
+
ret = curr.pop
|
169
|
+
ENV[ENVVAR] = curr.join(':')
|
170
|
+
ret.nil? ? nil : ret.to_sym
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
data/lib/dev/ui/glyph.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'dev/ui'
|
2
|
+
|
3
|
+
module Dev
|
4
|
+
module UI
|
5
|
+
class Glyph
|
6
|
+
MAP = {}
|
7
|
+
|
8
|
+
attr_reader :handle, :codepoint, :color, :char, :to_s, :fmt
|
9
|
+
def initialize(handle, codepoint, color)
|
10
|
+
@handle = handle
|
11
|
+
@codepoint = codepoint
|
12
|
+
@color = color
|
13
|
+
@char = [codepoint].pack('U')
|
14
|
+
@to_s = color.code + char + Color::RESET.code
|
15
|
+
@fmt = "{{#{color.name}:#{char}}}"
|
16
|
+
|
17
|
+
MAP[handle] = self
|
18
|
+
end
|
19
|
+
|
20
|
+
STAR = new('*', 0x2b51, Color::YELLOW) # BLACK SMALL STAR
|
21
|
+
INFO = new('i', 0x1d4be, Color::BLUE) # MATHEMATICAL SCRIPT SMALL I
|
22
|
+
QUESTION = new('?', 0x003f, Color::BLUE) # QUESTION MARK
|
23
|
+
CHECK = new('v', 0x2713, Color::GREEN) # CHECK MARK
|
24
|
+
X = new('x', 0x2717, Color::RED) # BALLOT X
|
25
|
+
|
26
|
+
class InvalidGlyphHandle < ArgumentError
|
27
|
+
def initialize(handle)
|
28
|
+
@handle = handle
|
29
|
+
end
|
30
|
+
|
31
|
+
def message
|
32
|
+
keys = Glyph.available.join(',')
|
33
|
+
"invalid glyph handle: #{@handle} " \
|
34
|
+
"-- must be one of Dev::UI::Glyph.available (#{keys})"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.lookup(name)
|
39
|
+
MAP.fetch(name.to_s)
|
40
|
+
rescue KeyError
|
41
|
+
raise InvalidGlyphHandle, name
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.available
|
45
|
+
MAP.keys
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
require 'io/console'
|
2
|
+
|
3
|
+
module Dev
|
4
|
+
module UI
|
5
|
+
class InteractivePrompt
|
6
|
+
def self.call(options)
|
7
|
+
list = new(options)
|
8
|
+
options[list.call - 1]
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(options)
|
12
|
+
@options = options
|
13
|
+
@active = 1
|
14
|
+
@marker = '>'
|
15
|
+
@answer = nil
|
16
|
+
end
|
17
|
+
|
18
|
+
def call
|
19
|
+
Dev::UI.raw { print(ANSI.hide_cursor) }
|
20
|
+
while @answer.nil?
|
21
|
+
render_options
|
22
|
+
wait_for_user_input
|
23
|
+
|
24
|
+
# This will put us back at the beginning of the options
|
25
|
+
# When we redraw the options, they will be overwritten
|
26
|
+
Dev::UI.raw do
|
27
|
+
@options.size.times { print(ANSI.previous_line) }
|
28
|
+
print(ANSI.previous_line + ANSI.end_of_line + "\n")
|
29
|
+
end
|
30
|
+
end
|
31
|
+
render_options
|
32
|
+
@answer
|
33
|
+
ensure
|
34
|
+
Dev::UI.raw do
|
35
|
+
print(ANSI.show_cursor)
|
36
|
+
puts(ANSI.previous_line + ANSI.end_of_line)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def wait_for_user_input
|
43
|
+
char = read_char
|
44
|
+
char = char.chomp unless char.chomp.empty?
|
45
|
+
case char
|
46
|
+
when "\e[A", 'k' # up
|
47
|
+
@active = @active - 1 >= 1 ? @active - 1 : @options.length
|
48
|
+
when "\e[B", 'j' # down
|
49
|
+
@active = @active + 1 <= @options.length ? @active + 1 : 1
|
50
|
+
when " ", "\r" # enter/select
|
51
|
+
@answer = @active
|
52
|
+
when ('1'..@options.size.to_s)
|
53
|
+
@active = char.to_i
|
54
|
+
@answer = char.to_i
|
55
|
+
when 'y', 'n'
|
56
|
+
return unless (@options - %w(yes no)).empty?
|
57
|
+
opt = @options.detect { |o| o.start_with?(char) }
|
58
|
+
@active = @options.index(opt) + 1
|
59
|
+
@answer = @options.index(opt) + 1
|
60
|
+
when "\u0003", "\e" # Control-C or escape
|
61
|
+
raise Interrupt
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Will handle 2-3 character sequences like arrow keys and control-c
|
66
|
+
def read_char
|
67
|
+
raw_tty! do
|
68
|
+
input = $stdin.getc.chr
|
69
|
+
return input unless input == "\e"
|
70
|
+
|
71
|
+
input << begin
|
72
|
+
$stdin.read_nonblock(3)
|
73
|
+
rescue
|
74
|
+
''
|
75
|
+
end
|
76
|
+
input << begin
|
77
|
+
$stdin.read_nonblock(2)
|
78
|
+
rescue
|
79
|
+
''
|
80
|
+
end
|
81
|
+
input
|
82
|
+
end
|
83
|
+
rescue IOError
|
84
|
+
"\e"
|
85
|
+
end
|
86
|
+
|
87
|
+
def raw_tty!
|
88
|
+
begin
|
89
|
+
$stdin.raw! unless ENV['TEST']
|
90
|
+
rescue Errno::ENOTTY
|
91
|
+
''
|
92
|
+
end
|
93
|
+
yield
|
94
|
+
ensure
|
95
|
+
begin
|
96
|
+
$stdin.cooked!
|
97
|
+
rescue Errno::ENOTTY
|
98
|
+
''
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def render_options
|
103
|
+
@options.each_with_index do |choice, index|
|
104
|
+
num = index + 1
|
105
|
+
message = " #{num}. {{bold:#{choice}}}"
|
106
|
+
message = "{{blue:> #{message.strip}}}" if num == @active
|
107
|
+
Dev::UI.with_frame_color(:blue) do
|
108
|
+
puts Dev::UI.fmt(message)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|