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