infopark-user_io 1.2.1 → 1.4.0

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