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,65 @@
|
|
1
|
+
require 'dev/ui'
|
2
|
+
|
3
|
+
module Dev
|
4
|
+
module UI
|
5
|
+
class Progress
|
6
|
+
FILLED_BAR = Dev::UI::Glyph.new("◾", 0x2588, Color::CYAN)
|
7
|
+
UNFILLED_BAR = Dev::UI::Glyph.new("◽", 0x2588, Color::WHITE)
|
8
|
+
|
9
|
+
# Set the percent to X
|
10
|
+
# Dev::UI::Progress.progress do |bar|
|
11
|
+
# bar.tick(set_percent: percent)
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# Increase the percent by 1
|
15
|
+
# Dev::UI::Progress.progress do |bar|
|
16
|
+
# bar.tick
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# Increase the percent by X
|
20
|
+
# Dev::UI::Progress.progress do |bar|
|
21
|
+
# bar.tick(percent: 5)
|
22
|
+
# end
|
23
|
+
def self.progress
|
24
|
+
bar = Progress.new
|
25
|
+
print Dev::UI::ANSI.hide_cursor
|
26
|
+
yield(bar)
|
27
|
+
ensure
|
28
|
+
puts bar.to_s
|
29
|
+
Dev::UI.raw do
|
30
|
+
print(ANSI.show_cursor)
|
31
|
+
puts(ANSI.previous_line + ANSI.end_of_line)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def initialize(width: Terminal.width)
|
36
|
+
@percent_done = 0
|
37
|
+
@max_width = width
|
38
|
+
end
|
39
|
+
|
40
|
+
def tick(percent: 0.01, set_percent: nil)
|
41
|
+
raise ArgumentError, 'percent and set_percent cannot both be specified' if percent != 0.01 && set_percent
|
42
|
+
@percent_done += percent
|
43
|
+
@percent_done = set_percent if set_percent
|
44
|
+
@percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
|
45
|
+
|
46
|
+
print to_s
|
47
|
+
print Dev::UI::ANSI.previous_line
|
48
|
+
print Dev::UI::ANSI.end_of_line + "\n"
|
49
|
+
end
|
50
|
+
|
51
|
+
def to_s
|
52
|
+
suffix = " #{(@percent_done * 100).round(2)}%"
|
53
|
+
workable_width = @max_width - Frame.prefix_width - suffix.size
|
54
|
+
filled = (@percent_done * workable_width.to_f).ceil
|
55
|
+
unfilled = workable_width - filled
|
56
|
+
|
57
|
+
Dev::UI.resolve_text [
|
58
|
+
(FILLED_BAR.to_s * filled),
|
59
|
+
(UNFILLED_BAR.to_s * unfilled),
|
60
|
+
suffix
|
61
|
+
].join
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'dev/ui'
|
2
|
+
require 'readline'
|
3
|
+
|
4
|
+
module Dev
|
5
|
+
module UI
|
6
|
+
module Prompt
|
7
|
+
class << self
|
8
|
+
def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true)
|
9
|
+
if (default && !allow_empty) || (options && (default || is_file))
|
10
|
+
raise(ArgumentError, 'conflicting arguments')
|
11
|
+
end
|
12
|
+
|
13
|
+
if default
|
14
|
+
puts_question("#{question} (empty = #{default})")
|
15
|
+
elsif options
|
16
|
+
puts_question("#{question} {{yellow:(choose with ↑ ↓ ⏎)}}")
|
17
|
+
else
|
18
|
+
puts_question(question)
|
19
|
+
end
|
20
|
+
|
21
|
+
return InteractivePrompt.call(options) if options
|
22
|
+
|
23
|
+
loop do
|
24
|
+
line = readline(is_file: is_file)
|
25
|
+
|
26
|
+
if line.empty? && default
|
27
|
+
write_default_over_empty_input(default)
|
28
|
+
return default
|
29
|
+
end
|
30
|
+
|
31
|
+
if !line.empty? || allow_empty
|
32
|
+
return line
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def confirm(question)
|
38
|
+
puts_question("#{question} {{yellow:(choose with ↑ ↓ ⏎)}}")
|
39
|
+
InteractivePrompt.call(%w(yes no)) == 'yes'
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def write_default_over_empty_input(default)
|
45
|
+
Dev::UI.raw do
|
46
|
+
STDERR.puts(
|
47
|
+
Dev::UI::ANSI.cursor_up(1) +
|
48
|
+
"\r" +
|
49
|
+
Dev::UI::ANSI.cursor_forward(4) + # TODO: width
|
50
|
+
default +
|
51
|
+
Dev::UI::Color::RESET.code
|
52
|
+
)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def puts_question(str)
|
57
|
+
Dev::UI.with_frame_color(:blue) do
|
58
|
+
STDOUT.puts(Dev::UI.fmt('{{?}} ' + str))
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def readline(is_file: false)
|
63
|
+
if is_file
|
64
|
+
Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
|
65
|
+
Readline.completion_append_character = ""
|
66
|
+
else
|
67
|
+
Readline.completion_proc = proc { |*| nil }
|
68
|
+
Readline.completion_append_character = " "
|
69
|
+
end
|
70
|
+
|
71
|
+
# because Readline is a C library, Dev::UI's hooks into $stdout don't
|
72
|
+
# work. We could work around this by having Dev::UI use a pipe and a
|
73
|
+
# thread to manage output, but the current strategy feels like a
|
74
|
+
# better tradeoff.
|
75
|
+
prefix = Dev::UI.with_frame_color(:blue) { Dev::UI::Frame.prefix }
|
76
|
+
prompt = prefix + Dev::UI.fmt('{{blue:> }}{{yellow:')
|
77
|
+
|
78
|
+
begin
|
79
|
+
Readline.readline(prompt, true).chomp
|
80
|
+
rescue Interrupt
|
81
|
+
Dev::UI.raw { STDERR.puts('^C' + Dev::UI::Color::RESET.code) }
|
82
|
+
raise
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,168 @@
|
|
1
|
+
require 'dev/ui'
|
2
|
+
|
3
|
+
module Dev
|
4
|
+
module UI
|
5
|
+
module Spinner
|
6
|
+
PERIOD = 0.1 # seconds
|
7
|
+
|
8
|
+
begin
|
9
|
+
runes = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
10
|
+
colors = [Dev::UI::Color::CYAN.code] * 5 + [Dev::UI::Color::MAGENTA.code] * 5
|
11
|
+
raise unless runes.size == colors.size
|
12
|
+
GLYPHS = colors.zip(runes).map(&:join)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.spin(title, &block)
|
16
|
+
sg = SpinGroup.new
|
17
|
+
sg.add(title, &block)
|
18
|
+
sg.wait
|
19
|
+
end
|
20
|
+
|
21
|
+
class SpinGroup
|
22
|
+
def initialize
|
23
|
+
@m = Mutex.new
|
24
|
+
@consumed_lines = 0
|
25
|
+
@tasks = []
|
26
|
+
end
|
27
|
+
|
28
|
+
class Task
|
29
|
+
attr_reader :title, :exception, :success, :stdout, :stderr
|
30
|
+
|
31
|
+
def initialize(title, &block)
|
32
|
+
@title = title
|
33
|
+
@thread = Thread.new do
|
34
|
+
cap = Dev::UI::StdoutRouter::Capture.new(with_frame_inset: false, &block)
|
35
|
+
begin
|
36
|
+
cap.run
|
37
|
+
ensure
|
38
|
+
@stdout = cap.stdout
|
39
|
+
@stderr = cap.stderr
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
@done = false
|
44
|
+
@exception = nil
|
45
|
+
@success = false
|
46
|
+
end
|
47
|
+
|
48
|
+
def check
|
49
|
+
return true if @done
|
50
|
+
return false if @thread.alive?
|
51
|
+
|
52
|
+
@done = true
|
53
|
+
begin
|
54
|
+
status = @thread.join.status
|
55
|
+
@success = (status == false)
|
56
|
+
rescue => exc
|
57
|
+
@exception = exc
|
58
|
+
@success = false
|
59
|
+
end
|
60
|
+
|
61
|
+
@done
|
62
|
+
end
|
63
|
+
|
64
|
+
def render(index, force = true)
|
65
|
+
return full_render(index) if force
|
66
|
+
partial_render(index)
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def full_render(index)
|
72
|
+
inset + glyph(index) + Dev::UI::Color::RESET.code + ' ' + Dev::UI.resolve_text(title)
|
73
|
+
end
|
74
|
+
|
75
|
+
def partial_render(index)
|
76
|
+
Dev::UI::ANSI.cursor_forward(inset_width) + glyph(index) + Dev::UI::Color::RESET.code
|
77
|
+
end
|
78
|
+
|
79
|
+
def glyph(index)
|
80
|
+
if @done
|
81
|
+
@success ? Dev::UI::Glyph::CHECK.to_s : Dev::UI::Glyph::X.to_s
|
82
|
+
else
|
83
|
+
GLYPHS[index]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def inset
|
88
|
+
@inset ||= Dev::UI::Frame.prefix
|
89
|
+
end
|
90
|
+
|
91
|
+
def inset_width
|
92
|
+
@inset_width ||= Dev::UI::ANSI.printing_width(inset)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def add(title, &block)
|
97
|
+
@m.synchronize do
|
98
|
+
@tasks << Task.new(title, &block)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def wait
|
103
|
+
idx = 0
|
104
|
+
|
105
|
+
loop do
|
106
|
+
all_done = true
|
107
|
+
|
108
|
+
@m.synchronize do
|
109
|
+
Dev::UI.raw do
|
110
|
+
@tasks.each.with_index do |task, int_index|
|
111
|
+
nat_index = int_index + 1
|
112
|
+
task_done = task.check
|
113
|
+
all_done = false unless task_done
|
114
|
+
|
115
|
+
if nat_index > @consumed_lines
|
116
|
+
print(task.render(idx, true) + "\n")
|
117
|
+
@consumed_lines += 1
|
118
|
+
else
|
119
|
+
offset = @consumed_lines - int_index
|
120
|
+
move_to = Dev::UI::ANSI.cursor_up(offset) + "\r"
|
121
|
+
move_from = "\r" + Dev::UI::ANSI.cursor_down(offset)
|
122
|
+
|
123
|
+
print(move_to + task.render(idx, idx.zero?) + move_from)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
break if all_done
|
130
|
+
|
131
|
+
idx = (idx + 1) % GLYPHS.size
|
132
|
+
sleep(PERIOD)
|
133
|
+
end
|
134
|
+
|
135
|
+
debrief
|
136
|
+
end
|
137
|
+
|
138
|
+
def debrief
|
139
|
+
@m.synchronize do
|
140
|
+
@tasks.each do |task|
|
141
|
+
next if task.success
|
142
|
+
|
143
|
+
e = task.exception
|
144
|
+
out = task.stdout
|
145
|
+
err = task.stderr
|
146
|
+
|
147
|
+
Dev::UI::Frame.open('Task Failed: ' + task.title, color: :red) do
|
148
|
+
if e
|
149
|
+
puts"#{e.class}: #{e.message}"
|
150
|
+
puts "\tfrom #{e.backtrace.join("\n\tfrom ")}"
|
151
|
+
end
|
152
|
+
|
153
|
+
Dev::UI::Frame.divider('STDOUT')
|
154
|
+
out = "(empty)" if out.strip.empty?
|
155
|
+
puts out
|
156
|
+
|
157
|
+
Dev::UI::Frame.divider('STDERR')
|
158
|
+
err = "(empty)" if err.strip.empty?
|
159
|
+
puts err
|
160
|
+
end
|
161
|
+
end
|
162
|
+
@tasks.all?(&:success)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
require 'dev/ui'
|
2
|
+
require 'stringio'
|
3
|
+
|
4
|
+
module Dev
|
5
|
+
module UI
|
6
|
+
module StdoutRouter
|
7
|
+
class << self
|
8
|
+
attr_accessor :duplicate_output_to
|
9
|
+
end
|
10
|
+
|
11
|
+
class Writer
|
12
|
+
def initialize(stream, name)
|
13
|
+
@stream = stream
|
14
|
+
@name = name
|
15
|
+
end
|
16
|
+
|
17
|
+
def write(*args)
|
18
|
+
if auto_frame_inset?
|
19
|
+
str = args[0].dup # unfreeze
|
20
|
+
str = str.force_encoding(Encoding::UTF_8)
|
21
|
+
str = apply_line_prefix(str, Dev::UI::Frame.prefix)
|
22
|
+
args[0] = str
|
23
|
+
else
|
24
|
+
@pending_newline = false
|
25
|
+
end
|
26
|
+
|
27
|
+
hook = Thread.current[:devui_output_hook]
|
28
|
+
# hook return of false suppresses output.
|
29
|
+
if !hook || hook.call(args.first, @name) != false
|
30
|
+
args.first
|
31
|
+
@stream.write_without_dev_ui(*args)
|
32
|
+
if dup = StdoutRouter.duplicate_output_to
|
33
|
+
dup.write(*args)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def auto_frame_inset?
|
41
|
+
!Thread.current[:no_devui_frame_inset]
|
42
|
+
end
|
43
|
+
|
44
|
+
def apply_line_prefix(str, prefix)
|
45
|
+
return '' if str.empty?
|
46
|
+
prefixed = String.new
|
47
|
+
str.force_encoding(Encoding::UTF_8).lines.each do |line|
|
48
|
+
if @pending_newline
|
49
|
+
prefixed << line
|
50
|
+
@pending_newline = false
|
51
|
+
else
|
52
|
+
prefixed << prefix << line
|
53
|
+
end
|
54
|
+
end
|
55
|
+
@pending_newline = !str.end_with?("\n")
|
56
|
+
prefixed
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
class Capture
|
61
|
+
@m = Mutex.new
|
62
|
+
@active_captures = 0
|
63
|
+
@saved_stdin = nil
|
64
|
+
|
65
|
+
def self.with_stdin_masked
|
66
|
+
@m.synchronize do
|
67
|
+
if @active_captures.zero?
|
68
|
+
@saved_stdin = $stdin
|
69
|
+
$stdin, w = IO.pipe
|
70
|
+
$stdin.close
|
71
|
+
w.close
|
72
|
+
end
|
73
|
+
@active_captures += 1
|
74
|
+
end
|
75
|
+
|
76
|
+
yield
|
77
|
+
ensure
|
78
|
+
@m.synchronize do
|
79
|
+
@active_captures -= 1
|
80
|
+
if @active_captures.zero?
|
81
|
+
$stdin = @saved_stdin
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def initialize(with_frame_inset: true, &block)
|
87
|
+
@with_frame_inset = with_frame_inset
|
88
|
+
@block = block
|
89
|
+
end
|
90
|
+
|
91
|
+
attr_reader :stdout, :stderr
|
92
|
+
|
93
|
+
def run
|
94
|
+
require 'stringio'
|
95
|
+
|
96
|
+
StdoutRouter.assert_enabled!
|
97
|
+
|
98
|
+
out = StringIO.new
|
99
|
+
err = StringIO.new
|
100
|
+
|
101
|
+
prev_frame_inset = Thread.current[:no_devui_frame_inset]
|
102
|
+
prev_hook = Thread.current[:devui_output_hook]
|
103
|
+
|
104
|
+
self.class.with_stdin_masked do
|
105
|
+
Thread.current[:no_devui_frame_inset] = !@with_frame_inset
|
106
|
+
Thread.current[:devui_output_hook] = ->(data, stream) do
|
107
|
+
case stream
|
108
|
+
when :stdout then out.write(data)
|
109
|
+
when :stderr then err.write(data)
|
110
|
+
else raise
|
111
|
+
end
|
112
|
+
false # suppress writing to terminal
|
113
|
+
end
|
114
|
+
|
115
|
+
begin
|
116
|
+
@block.call
|
117
|
+
ensure
|
118
|
+
@stdout = out.string
|
119
|
+
@stderr = err.string
|
120
|
+
end
|
121
|
+
end
|
122
|
+
ensure
|
123
|
+
Thread.current[:devui_output_hook] = prev_hook
|
124
|
+
Thread.current[:no_devui_frame_inset] = prev_frame_inset
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
class << self
|
129
|
+
WRITE_WITHOUT_DEV_UI = :write_without_dev_ui
|
130
|
+
|
131
|
+
NotEnabled = Class.new(StandardError)
|
132
|
+
|
133
|
+
def assert_enabled!
|
134
|
+
raise NotEnabled unless enabled?
|
135
|
+
end
|
136
|
+
|
137
|
+
def with_enabled
|
138
|
+
enable
|
139
|
+
yield
|
140
|
+
ensure
|
141
|
+
disable
|
142
|
+
end
|
143
|
+
|
144
|
+
# TODO: remove this
|
145
|
+
def ensure_activated
|
146
|
+
enable unless enabled?
|
147
|
+
end
|
148
|
+
|
149
|
+
def enable
|
150
|
+
return false if enabled?($stdout) || enabled?($stderr)
|
151
|
+
activate($stdout, :stdout)
|
152
|
+
activate($stderr, :stderr)
|
153
|
+
true
|
154
|
+
end
|
155
|
+
|
156
|
+
def enabled?(stream = $stdout)
|
157
|
+
stream.respond_to?(WRITE_WITHOUT_DEV_UI)
|
158
|
+
end
|
159
|
+
|
160
|
+
def disable
|
161
|
+
return false unless enabled?($stdout) && enabled?($stderr)
|
162
|
+
deactivate($stdout)
|
163
|
+
deactivate($stderr)
|
164
|
+
true
|
165
|
+
end
|
166
|
+
|
167
|
+
private
|
168
|
+
|
169
|
+
def deactivate(stream)
|
170
|
+
sc = stream.singleton_class
|
171
|
+
sc.send(:remove_method, :write)
|
172
|
+
sc.send(:alias_method, :write, WRITE_WITHOUT_DEV_UI)
|
173
|
+
end
|
174
|
+
|
175
|
+
def activate(stream, streamname)
|
176
|
+
writer = StdoutRouter::Writer.new(stream, streamname)
|
177
|
+
|
178
|
+
raise if stream.respond_to?(WRITE_WITHOUT_DEV_UI)
|
179
|
+
stream.singleton_class.send(:alias_method, WRITE_WITHOUT_DEV_UI, :write)
|
180
|
+
stream.define_singleton_method(:write) do |*args|
|
181
|
+
writer.write(*args)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
Dev::UI::StdoutRouter.enable
|