infopark-user_io 1.2.1 → 1.3.0
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 +5 -5
- data/.rubocop.yml +142 -0
- data/.ruby-version +1 -0
- data/Gemfile +9 -0
- data/Rakefile +3 -1
- data/lib/infopark/user_io/global.rb +10 -4
- data/lib/infopark/user_io/version.rb +3 -1
- data/lib/infopark/user_io.rb +263 -254
- data/lib/infopark-user_io.rb +3 -1
- data/spec/progress_spec.rb +114 -115
- data/spec/spec_helper.rb +51 -51
- data/spec/user_io/global_spec.rb +7 -5
- data/spec/user_io_spec.rb +297 -305
- data/user_io.gemspec +10 -10
- metadata +13 -53
data/lib/infopark/user_io.rb
CHANGED
@@ -1,203 +1,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Infopark
|
4
|
+
# TODO: extract into infopark base gem
|
5
|
+
class ImplementationError < StandardError; end
|
2
6
|
|
3
|
-
# TODO
|
4
|
-
|
7
|
+
# TODO
|
8
|
+
# - beep (\a) on #acknowledge, #ask or #confirm (and maybe on #listen, too)
|
9
|
+
class UserIO
|
10
|
+
class Aborted < RuntimeError
|
11
|
+
end
|
5
12
|
|
6
|
-
|
7
|
-
|
8
|
-
class UserIO
|
9
|
-
class Aborted < RuntimeError
|
10
|
-
end
|
13
|
+
class MissingEnv < RuntimeError
|
14
|
+
end
|
11
15
|
|
12
|
-
|
13
|
-
|
16
|
+
class Progress
|
17
|
+
def initialize(label, user_io)
|
18
|
+
@label = label
|
19
|
+
@user_io = user_io
|
20
|
+
@spinner = "-\\|/"
|
21
|
+
end
|
14
22
|
|
15
|
-
|
16
|
-
|
17
|
-
@label = label
|
18
|
-
@user_io = user_io
|
19
|
-
@spinner = "-\\|/"
|
20
|
-
end
|
23
|
+
def start
|
24
|
+
return if @started
|
21
25
|
|
22
|
-
def start
|
23
|
-
unless @started
|
24
26
|
user_io.tell("#{label} ", newline: false)
|
25
27
|
@started = true
|
26
28
|
reset_spinner
|
27
29
|
end
|
28
|
-
end
|
29
30
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
31
|
+
def increment
|
32
|
+
raise(ImplementationError, "progress not started yet") unless @started
|
33
|
+
|
34
|
+
user_io.tell(".", newline: false)
|
35
|
+
reset_spinner
|
36
|
+
end
|
37
|
+
|
38
|
+
def finish
|
39
|
+
return unless @started
|
35
40
|
|
36
|
-
def finish
|
37
|
-
if @started
|
38
41
|
user_io.tell("… ", newline: false)
|
39
42
|
user_io.tell("OK", color: :green, bright: true)
|
40
43
|
@started = false
|
41
44
|
end
|
42
|
-
end
|
43
45
|
|
44
|
-
|
45
|
-
|
46
|
-
user_io.tell("#{@spinner[@spin_pos % @spinner.size]}\b", newline: false)
|
47
|
-
@spin_pos += 1
|
48
|
-
end
|
46
|
+
def spin
|
47
|
+
raise(ImplementationError, "progress not started yet") unless @started
|
49
48
|
|
50
|
-
|
49
|
+
user_io.tell("#{@spinner[@spin_pos % @spinner.size]}\b", newline: false)
|
50
|
+
@spin_pos += 1
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
51
54
|
|
52
|
-
|
55
|
+
attr_reader :label
|
56
|
+
attr_reader :user_io
|
53
57
|
|
54
|
-
|
55
|
-
|
58
|
+
def reset_spinner
|
59
|
+
@spin_pos = 0
|
60
|
+
end
|
56
61
|
end
|
57
|
-
end
|
58
62
|
|
59
|
-
|
60
|
-
|
61
|
-
|
63
|
+
class << self
|
64
|
+
attr_accessor :global
|
65
|
+
end
|
62
66
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
67
|
+
def initialize(output_prefix: nil)
|
68
|
+
case output_prefix
|
69
|
+
when String
|
70
|
+
@output_prefix = "[#{output_prefix}] "
|
71
|
+
when Proc, Method
|
72
|
+
@output_prefix_proc = -> { "[#{output_prefix.call}] " }
|
73
|
+
when :timestamp
|
74
|
+
@output_prefix_proc = -> { "[#{Time.now.strftime('%T.%L')}] " }
|
75
|
+
end
|
76
|
+
@line_pending = {}
|
71
77
|
end
|
72
|
-
@line_pending = {}
|
73
|
-
end
|
74
78
|
|
75
|
-
|
76
|
-
|
79
|
+
def tell(*texts, newline: true, **line_options)
|
80
|
+
lines = texts.flatten.map {|text| text.to_s.split("\n", -1) }.flatten
|
77
81
|
|
78
|
-
|
79
|
-
|
80
|
-
|
82
|
+
lines[0...-1].each {|line| tell_line(line, **line_options) }
|
83
|
+
tell_line(lines.last, newline:, **line_options)
|
84
|
+
end
|
81
85
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
86
|
+
def tell_pty_stream(stream, **color_options)
|
87
|
+
color_prefix, color_postfix = compute_color(**color_options)
|
88
|
+
write_raw(output_prefix) unless line_pending?
|
89
|
+
write_raw(color_prefix)
|
90
|
+
nl_pending = false
|
91
|
+
uncolored_prefix = "#{color_postfix}#{output_prefix}#{color_prefix}"
|
92
|
+
until stream.eof?
|
93
|
+
chunk = stream.read_nonblock(100)
|
94
|
+
next if chunk.empty?
|
95
|
+
|
96
|
+
write_raw("\n#{uncolored_prefix}") if nl_pending
|
97
|
+
chunk.chop! if (nl_pending = chunk.end_with?("\n"))
|
98
|
+
chunk.gsub!(/([\r\n])/, "\\1#{uncolored_prefix}")
|
99
|
+
write_raw(chunk)
|
100
|
+
end
|
101
|
+
write_raw("\n") if nl_pending
|
102
|
+
write_raw(color_postfix)
|
103
|
+
line_pending!(false)
|
104
|
+
end
|
100
105
|
|
101
|
-
|
102
|
-
|
103
|
-
|
106
|
+
def warn(*text)
|
107
|
+
tell(*text, color: :yellow, bright: true)
|
108
|
+
end
|
104
109
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
110
|
+
def tell_error(e, **options)
|
111
|
+
tell(e, **options, color: :red, bright: true)
|
112
|
+
tell(e.backtrace, **options, color: :red) if Exception === e
|
113
|
+
end
|
109
114
|
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
115
|
+
def acknowledge(*text)
|
116
|
+
tell("-" * 80)
|
117
|
+
tell(*text, color: :cyan, bright: true)
|
118
|
+
tell("-" * 80)
|
119
|
+
tell("Please press ENTER to continue.")
|
120
|
+
read_line
|
121
|
+
end
|
117
122
|
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
answer
|
129
|
-
|
123
|
+
def ask(*text, default: nil, expected: "yes")
|
124
|
+
# TODO
|
125
|
+
# - implementation error if default not boolean or nil
|
126
|
+
# - implementation error if expected not "yes" or "no"
|
127
|
+
tell("-" * 80)
|
128
|
+
tell(*text, color: :cyan, bright: true)
|
129
|
+
tell("-" * 80)
|
130
|
+
default_answer = default ? "yes" : "no" unless default.nil?
|
131
|
+
tell("(yes/no) #{default_answer && "[#{default_answer}] "}> ", newline: false)
|
132
|
+
until %w(yes no).include?(answer = read_line.strip.downcase)
|
133
|
+
if answer.empty?
|
134
|
+
answer = default_answer
|
135
|
+
break
|
136
|
+
end
|
137
|
+
tell("I couldn't understand “#{answer}”.", newline: false, color: :red, bright: true)
|
138
|
+
tell(" > ", newline: false)
|
130
139
|
end
|
131
|
-
|
132
|
-
tell(" > ", newline: false)
|
140
|
+
answer == expected
|
133
141
|
end
|
134
|
-
answer == expected
|
135
|
-
end
|
136
142
|
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
143
|
+
def listen(prompt = nil, **options)
|
144
|
+
prompt << " " if prompt
|
145
|
+
tell("#{prompt}> ", **options, newline: false)
|
146
|
+
read_line.strip
|
147
|
+
end
|
142
148
|
|
143
|
-
|
144
|
-
|
145
|
-
|
149
|
+
def confirm(*text)
|
150
|
+
ask(*text) or raise(Aborted)
|
151
|
+
end
|
146
152
|
|
147
|
-
|
148
|
-
|
149
|
-
|
153
|
+
def new_progress(label)
|
154
|
+
Progress.new(label, self)
|
155
|
+
end
|
150
156
|
|
151
|
-
|
152
|
-
|
153
|
-
|
157
|
+
def start_progress(label)
|
158
|
+
new_progress(label).tap(&:start)
|
159
|
+
end
|
160
|
+
|
161
|
+
def background_other_threads
|
162
|
+
return if @foreground_thread
|
154
163
|
|
155
|
-
def background_other_threads
|
156
|
-
unless @foreground_thread
|
157
164
|
@background_data = []
|
158
165
|
@foreground_thread = Thread.current
|
159
166
|
end
|
160
|
-
end
|
161
167
|
|
162
|
-
|
163
|
-
|
164
|
-
|
168
|
+
def foreground
|
169
|
+
return unless @foreground_thread
|
170
|
+
|
171
|
+
@background_data.each(&$stdout.method(:write))
|
165
172
|
@foreground_thread = nil
|
166
173
|
# take over line_pending from background
|
167
174
|
@line_pending[false] = @line_pending[true]
|
168
175
|
@line_pending[true] = false
|
169
176
|
end
|
170
|
-
end
|
171
177
|
|
172
|
-
|
173
|
-
|
174
|
-
|
178
|
+
def <<(msg)
|
179
|
+
tell(msg.chomp, newline: msg.end_with?("\n"))
|
180
|
+
end
|
175
181
|
|
176
|
-
|
177
|
-
|
178
|
-
|
182
|
+
def tty?
|
183
|
+
$stdout.tty?
|
184
|
+
end
|
179
185
|
|
180
|
-
|
181
|
-
|
186
|
+
def edit_file(kind_of_data, filename = nil, template: nil)
|
187
|
+
wait_for_foreground if background?
|
182
188
|
|
183
|
-
|
189
|
+
editor = ENV.fetch("EDITOR", nil) or raise(MissingEnv, "No EDITOR specified.")
|
184
190
|
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
191
|
+
filename ||= Tempfile.new("").path
|
192
|
+
if template && (!File.exist?(filename) || File.empty?(filename))
|
193
|
+
File.write(filename, template)
|
194
|
+
end
|
189
195
|
|
190
|
-
|
191
|
-
|
192
|
-
|
196
|
+
tell("Start editing #{kind_of_data} using #{editor}…")
|
197
|
+
sleep(1.7)
|
198
|
+
system(editor, filename)
|
193
199
|
|
194
|
-
|
195
|
-
|
200
|
+
File.read(filename)
|
201
|
+
end
|
196
202
|
|
197
|
-
|
198
|
-
|
203
|
+
def select(description, items, item_describer: :to_s, default: nil)
|
204
|
+
return if items.empty?
|
199
205
|
|
200
|
-
|
206
|
+
describer =
|
201
207
|
case item_describer
|
202
208
|
when Method, Proc
|
203
209
|
item_describer
|
@@ -205,141 +211,144 @@ class UserIO
|
|
205
211
|
->(item) { item.send(item_describer) }
|
206
212
|
end
|
207
213
|
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
+
choice = nil
|
215
|
+
if items.size == 1
|
216
|
+
choice = items.first
|
217
|
+
tell("Selected #{describer.call(choice)}.", color: :yellow)
|
218
|
+
return choice
|
219
|
+
end
|
214
220
|
|
215
|
-
|
221
|
+
items = items.sort_by(&describer)
|
216
222
|
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
else
|
231
|
-
int_answer = answer.to_i
|
232
|
-
if int_answer.to_s != answer
|
233
|
-
tell("Please enter a valid integer.")
|
234
|
-
elsif int_answer < 1 || int_answer > items.size
|
235
|
-
tell("Please enter a number from 1 through #{items.size}.")
|
223
|
+
tell("-" * 80)
|
224
|
+
tell("Please select #{description}:", color: :cyan, bright: true)
|
225
|
+
items.each_with_index do |item, i|
|
226
|
+
tell("#{i + 1}: #{describer.call(item)}", color: :cyan, bright: true)
|
227
|
+
end
|
228
|
+
tell("-" * 80)
|
229
|
+
default_index = items.index(default)
|
230
|
+
default_selection = "[#{default_index + 1}] " if default_index
|
231
|
+
until choice
|
232
|
+
tell("Your choice #{default_selection}> ", newline: false)
|
233
|
+
answer = read_line.strip
|
234
|
+
if answer.empty?
|
235
|
+
choice = default
|
236
236
|
else
|
237
|
-
|
237
|
+
int_answer = answer.to_i
|
238
|
+
if int_answer.to_s != answer
|
239
|
+
tell("Please enter a valid integer.")
|
240
|
+
elsif int_answer < 1 || int_answer > items.size
|
241
|
+
tell("Please enter a number from 1 through #{items.size}.")
|
242
|
+
else
|
243
|
+
choice = items[int_answer - 1]
|
244
|
+
end
|
238
245
|
end
|
239
246
|
end
|
247
|
+
choice
|
240
248
|
end
|
241
|
-
choice
|
242
|
-
end
|
243
249
|
|
244
|
-
|
250
|
+
private
|
245
251
|
|
246
|
-
|
247
|
-
|
248
|
-
|
252
|
+
def background?
|
253
|
+
!!@foreground_thread && @foreground_thread != Thread.current
|
254
|
+
end
|
249
255
|
|
250
|
-
|
251
|
-
|
252
|
-
|
256
|
+
def wait_for_foreground
|
257
|
+
sleep(0.1) while background?
|
258
|
+
end
|
253
259
|
|
254
|
-
|
255
|
-
|
256
|
-
|
260
|
+
def output_prefix
|
261
|
+
@output_prefix || @output_prefix_proc&.call
|
262
|
+
end
|
257
263
|
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
264
|
+
def read_line
|
265
|
+
wait_for_foreground if background?
|
266
|
+
@line_pending[false] = false
|
267
|
+
$stdin.gets.chomp
|
268
|
+
end
|
263
269
|
|
264
|
-
|
265
|
-
|
266
|
-
|
270
|
+
def tell_line(line, newline: true, prefix: true, **color_options)
|
271
|
+
line_prefix, line_postfix = compute_color(**color_options)
|
272
|
+
prefix = false if line_pending?
|
267
273
|
|
268
|
-
|
269
|
-
|
274
|
+
out_line = "#{output_prefix if prefix}#{line_prefix}#{line}#{line_postfix}#{"\n" if newline}"
|
275
|
+
write_raw(out_line)
|
270
276
|
|
271
|
-
|
272
|
-
|
277
|
+
line_pending!(!newline)
|
278
|
+
end
|
273
279
|
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
280
|
+
def write_raw(bytes)
|
281
|
+
if background?
|
282
|
+
@background_data << bytes
|
283
|
+
else
|
284
|
+
$stdout.write(bytes)
|
285
|
+
end
|
279
286
|
end
|
280
|
-
end
|
281
287
|
|
282
|
-
|
283
|
-
|
284
|
-
|
288
|
+
def line_pending?
|
289
|
+
@line_pending[background?]
|
290
|
+
end
|
285
291
|
|
286
|
-
|
287
|
-
|
288
|
-
|
292
|
+
def line_pending!(value)
|
293
|
+
@line_pending[background?] = value
|
294
|
+
end
|
289
295
|
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
# TODO matching annihilators for options
|
296
|
+
def compute_color(**options)
|
297
|
+
if tty? && (prefix = text_color(**options))
|
298
|
+
# TODO: matching annihilators for options
|
294
299
|
postfix = text_color(color: :none, bright: false)
|
295
300
|
end
|
301
|
+
[prefix, postfix]
|
296
302
|
end
|
297
|
-
[prefix, postfix]
|
298
|
-
end
|
299
303
|
|
300
|
-
|
301
|
-
|
302
|
-
|
304
|
+
def control_sequence(*parameters, function)
|
305
|
+
"\033[#{parameters.join(';')}#{function}"
|
306
|
+
end
|
303
307
|
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
+
# SGR: Select Graphic Rendition … far too long for a function name ;)
|
309
|
+
def sgr_sequence(*parameters)
|
310
|
+
control_sequence(*parameters, :m)
|
311
|
+
end
|
308
312
|
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
313
|
+
def text_color(color: nil, bright: nil, faint: nil, italic: nil, underline: nil)
|
314
|
+
return if color.nil? && bright.nil?
|
315
|
+
|
316
|
+
sequence = []
|
317
|
+
unless bright.nil? && faint.nil?
|
318
|
+
sequence <<
|
319
|
+
if bright
|
320
|
+
1
|
321
|
+
else
|
322
|
+
faint ? 2 : 22
|
323
|
+
end
|
324
|
+
end
|
325
|
+
unless italic.nil?
|
326
|
+
sequence << (italic ? 3 : 23)
|
327
|
+
end
|
328
|
+
unless underline.nil?
|
329
|
+
sequence << (underline ? 4 : 24)
|
330
|
+
end
|
331
|
+
case color
|
332
|
+
when :red
|
333
|
+
sequence << 31
|
334
|
+
when :green
|
335
|
+
sequence << 32
|
336
|
+
when :yellow
|
337
|
+
sequence << 33
|
338
|
+
when :blue
|
339
|
+
sequence << 34
|
340
|
+
when :purple
|
341
|
+
sequence << 35
|
342
|
+
when :cyan
|
343
|
+
sequence << 36
|
344
|
+
when :white
|
345
|
+
sequence << 37
|
346
|
+
when :none
|
347
|
+
sequence << 39
|
348
|
+
end
|
349
|
+
sgr_sequence(*sequence)
|
350
|
+
end
|
340
351
|
end
|
341
352
|
end
|
342
353
|
|
343
|
-
|
344
|
-
|
345
|
-
require_relative 'user_io/global'
|
354
|
+
require_relative "user_io/global"
|
data/lib/infopark-user_io.rb
CHANGED