tty2-reader 0.9.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.
@@ -0,0 +1,632 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-cursor"
4
+ require "tty-screen"
5
+ require "wisper"
6
+
7
+ require_relative "reader/completer"
8
+ require_relative "reader/completion_event"
9
+ require_relative "reader/history"
10
+ require_relative "reader/line"
11
+ require_relative "reader/key_event"
12
+ require_relative "reader/console"
13
+ require_relative "reader/win_console"
14
+ require_relative "reader/version"
15
+
16
+ module TTY2
17
+ # A class responsible for reading character input from STDIN
18
+ #
19
+ # Used internally to provide key and line reading functionality
20
+ #
21
+ # @api public
22
+ class Reader
23
+ include Wisper::Publisher
24
+
25
+ # Key codes
26
+ BACKSPACE = 8
27
+ TAB = 9
28
+ NEWLINE = 10
29
+ CARRIAGE_RETURN = 13
30
+ DELETE = 127
31
+
32
+ # Keys that terminate input
33
+ EXIT_KEYS = %i[ctrl_d ctrl_z].freeze
34
+
35
+ # Pattern to check if line ends with a line break character
36
+ END_WITH_LINE_BREAK = /(\r|\n)$/.freeze
37
+
38
+ # Raised when the user hits the interrupt key(Control-C)
39
+ #
40
+ # @api public
41
+ InputInterrupt = Class.new(Interrupt)
42
+
43
+ # Check if Windowz mode
44
+ #
45
+ # @return [Boolean]
46
+ #
47
+ # @api public
48
+ def self.windows?
49
+ ::File::ALT_SEPARATOR == "\\"
50
+ end
51
+
52
+ attr_reader :input
53
+
54
+ attr_reader :output
55
+
56
+ attr_reader :env
57
+
58
+ attr_reader :track_history
59
+ alias track_history? track_history
60
+
61
+ # The handler for finding word completion suggestions
62
+ #
63
+ # @api public
64
+ attr_reader :completion_handler
65
+
66
+ # The suffix to add to suggested word completion
67
+ #
68
+ # @api public
69
+ attr_reader :completion_suffix
70
+
71
+ attr_reader :completion_cycling
72
+
73
+ attr_reader :console
74
+
75
+ attr_reader :cursor
76
+
77
+ # Initialize a Reader
78
+ #
79
+ # @param [IO] input
80
+ # the input stream
81
+ # @param [IO] output
82
+ # the output stream
83
+ # @param [Symbol] interrupt
84
+ # the way to handle the Ctrl+C key out of :signal, :exit, :noop
85
+ # @param [Hash] env
86
+ # the environment variables
87
+ # @param [Boolean] track_history
88
+ # disable line history tracking, true by default
89
+ # @param [Boolean] history_cycle
90
+ # allow cycling through history, false by default
91
+ # @param [Boolean] history_duplicates
92
+ # allow duplicate entires, false by default
93
+ # @param [Proc] history_exclude
94
+ # exclude lines from history, by default all lines are stored
95
+ # @param [Proc] completion_handler
96
+ # the hanlder for finding word completion suggestions
97
+ # @param [String] completion_suffix
98
+ # the suffix to add to suggested word completion
99
+ # @param [Boolean] completion_cycling
100
+ # enable cycling through completions, true by default
101
+ #
102
+ # @api public
103
+ def initialize(input: $stdin, output: $stdout, interrupt: :error,
104
+ env: ENV, track_history: true, history_cycle: false,
105
+ history_exclude: History::DEFAULT_EXCLUDE,
106
+ history_size: History::DEFAULT_SIZE,
107
+ history_duplicates: false,
108
+ completion_handler: nil, completion_suffix: "",
109
+ completion_cycling: true)
110
+ @input = input
111
+ @output = output
112
+ @interrupt = interrupt
113
+ @env = env
114
+ @track_history = track_history
115
+ @history_cycle = history_cycle
116
+ @history_exclude = history_exclude
117
+ @history_duplicates = history_duplicates
118
+ @history_size = history_size
119
+ @completion_handler = completion_handler
120
+ @completion_suffix = completion_suffix
121
+ @completion_cycling = completion_cycling
122
+ @completer = Completer.new(handler: completion_handler,
123
+ suffix: completion_suffix,
124
+ cycling: completion_cycling)
125
+ @console = select_console(input)
126
+ @history = History.new(history_size) do |h|
127
+ h.cycle = history_cycle
128
+ h.duplicates = history_duplicates
129
+ h.exclude = history_exclude
130
+ end
131
+ @cursor = TTY::Cursor
132
+ end
133
+
134
+ # Set completion handler
135
+ #
136
+ # @param [Proc] handler
137
+ # the handler for finding word completion suggestions
138
+ #
139
+ # @api public
140
+ def completion_handler=(handler)
141
+ @completion_handler = handler
142
+ @completer.handler = handler
143
+ end
144
+
145
+ # Set completion suffix
146
+ #
147
+ # @param [String] suffix
148
+ # the suffix to add to suggested word completion
149
+ #
150
+ # @api public
151
+ def completion_suffix=(suffix)
152
+ @completion_suffix = suffix
153
+ @completer.suffix = suffix
154
+ end
155
+
156
+ # Set completion cycling
157
+ #
158
+ # @param [Boolean] cycling
159
+ # whether to cycle through completion suggestionsor not, defaults to true
160
+ #
161
+ # @api public
162
+ def completion_cycling=(cycling)
163
+ @completion_cycling = cycling
164
+ @completer.cycling = cycling
165
+ end
166
+
167
+ alias old_subcribe subscribe
168
+
169
+ # Subscribe to receive key events
170
+ #
171
+ # @example
172
+ # reader.subscribe(MyListener.new)
173
+ #
174
+ # @return [self|yield]
175
+ #
176
+ # @api public
177
+ def subscribe(listener, options = {})
178
+ old_subcribe(listener, options)
179
+ object = self
180
+ if block_given?
181
+ object = yield
182
+ unsubscribe(listener)
183
+ end
184
+ object
185
+ end
186
+
187
+ # Unsubscribe from receiving key events
188
+ #
189
+ # @example
190
+ # reader.unsubscribe(my_listener)
191
+ #
192
+ # @return [void]
193
+ #
194
+ # @api public
195
+ def unsubscribe(listener)
196
+ registry = send(:local_registrations)
197
+ registry.each do |object|
198
+ if object.listener.equal?(listener)
199
+ registry.delete(object)
200
+ end
201
+ end
202
+ end
203
+
204
+ # Select appropriate console
205
+ #
206
+ # @api private
207
+ def select_console(input)
208
+ if self.class.windows? && !env["TTY_TEST"]
209
+ WinConsole.new(input)
210
+ else
211
+ Console.new(input)
212
+ end
213
+ end
214
+
215
+ # Get input in unbuffered mode.
216
+ #
217
+ # @example
218
+ # unbufferred do
219
+ # ...
220
+ # end
221
+ #
222
+ # @api public
223
+ def unbufferred(&block)
224
+ bufferring = output.sync
225
+ # Immediately flush output
226
+ output.sync = true
227
+ block[] if block_given?
228
+ ensure
229
+ output.sync = bufferring
230
+ end
231
+
232
+ # Read a keypress including invisible multibyte codes and return
233
+ # a character as a string.
234
+ # Nothing is echoed to the console. This call will block for a
235
+ # single keypress, but will not wait for Enter to be pressed.
236
+ #
237
+ # @param [Boolean] echo
238
+ # whether to echo chars back or not, defaults to false
239
+ # @option [Boolean] raw
240
+ # whenther raw mode is enabled, defaults to true
241
+ # @option [Boolean] nonblock
242
+ # whether to wait for input or not, defaults to false
243
+ #
244
+ # @return [String]
245
+ #
246
+ # @api public
247
+ def read_keypress(echo: false, raw: true, nonblock: false)
248
+ codes = unbufferred do
249
+ get_codes(echo: echo, raw: raw, nonblock: nonblock)
250
+ end
251
+ char = codes ? codes.pack("U*") : nil
252
+
253
+ trigger_key_event(char) if char
254
+ char
255
+ end
256
+ alias read_char read_keypress
257
+
258
+ # Get input code points
259
+ #
260
+ # @param [Boolean] echo
261
+ # whether to echo chars back or not, defaults to false
262
+ # @option [Boolean] raw
263
+ # whenther raw mode is enabled, defaults to true
264
+ # @option [Boolean] nonblock
265
+ # whether to wait for input or not, defaults to false
266
+ # @param [Array[Integer]] codes
267
+ # the currently read char code points
268
+ #
269
+ # @return [Array[Integer]]
270
+ #
271
+ # @api private
272
+ def get_codes(echo: true, raw: false, nonblock: false, codes: [])
273
+ char = console.get_char(echo: echo, raw: raw, nonblock: nonblock)
274
+ handle_interrupt if console.keys[char] == :ctrl_c
275
+ return if char.nil?
276
+
277
+ codes << char.ord
278
+ condition = proc { |escape|
279
+ (codes - escape).empty? ||
280
+ (escape - codes).empty? &&
281
+ !(64..126).cover?(codes.last)
282
+ }
283
+
284
+ while console.escape_codes.any?(&condition)
285
+ char_codes = get_codes(echo: echo, raw: raw,
286
+ nonblock: true, codes: codes)
287
+ break if char_codes.nil?
288
+ end
289
+
290
+ codes
291
+ end
292
+
293
+ # Get a single line from STDIN. Each key pressed is echoed
294
+ # back to the shell. The input terminates when enter or
295
+ # return key is pressed.
296
+ #
297
+ # @param [String] prompt
298
+ # the prompt to display before input
299
+ # @param [String] value
300
+ # the value to pre-populate line with
301
+ # @param [Boolean] echo
302
+ # whether to echo chars back or not, defaults to false
303
+ # @param [Array<Symbol>] exit_keys
304
+ # the custom keys to exit line editing
305
+ # @option [Boolean] raw
306
+ # whenther raw mode is enabled, defaults to true
307
+ # @option [Boolean] nonblock
308
+ # whether to wait for input or not, defaults to false
309
+ #
310
+ # @return [String]
311
+ #
312
+ # @api public
313
+ def read_line(prompt = "", value: "", echo: true, raw: true,
314
+ nonblock: false, exit_keys: nil)
315
+ line = Line.new(value, prompt: prompt)
316
+ screen_width = TTY::Screen.width
317
+ history_in_use = false
318
+ previous_key_name = ""
319
+ buffer = ""
320
+
321
+ output.print(line)
322
+
323
+ while (codes = get_codes(echo: echo, raw: raw, nonblock: nonblock)) &&
324
+ (code = codes[0])
325
+ char = codes.pack("U*")
326
+ key_name = console.keys[char]
327
+
328
+ if exit_keys && exit_keys.include?(key_name)
329
+ trigger_key_event(char, line: line)
330
+ break
331
+ end
332
+
333
+ if raw && echo
334
+ clear_display(line, screen_width)
335
+ end
336
+
337
+ if (key_name == :tab || code == TAB || key_name == :shift_tab) &&
338
+ completion_handler
339
+ initial = previous_key_name != :tab && previous_key_name != :shift_tab
340
+ direction = key_name == :shift_tab ? :previous : :next
341
+ if completion = @completer.complete(line, initial: initial,
342
+ direction: direction)
343
+ trigger_completion_event(completion, line.to_s)
344
+ end
345
+ elsif key_name == :escape && completion_handler &&
346
+ (previous_key_name == :tab || previous_key_name == :shift_tab)
347
+ @completer.cancel(line)
348
+ elsif key_name == :backspace || code == BACKSPACE
349
+ if !line.start?
350
+ line.left
351
+ line.delete
352
+ end
353
+ elsif key_name == :delete || code == DELETE
354
+ line.delete
355
+ elsif key_name.to_s =~ /ctrl_/
356
+ # skip
357
+ elsif key_name == :up
358
+ @history.replace(line.text) if history_in_use
359
+ if history_previous?
360
+ line.replace(history_previous(skip: !history_in_use))
361
+ history_in_use = true
362
+ end
363
+ elsif key_name == :down
364
+ @history.replace(line.text) if history_in_use
365
+ if history_next?
366
+ line.replace(history_next)
367
+ elsif history_in_use
368
+ line.replace(buffer)
369
+ history_in_use = false
370
+ end
371
+ elsif key_name == :left
372
+ line.left
373
+ elsif key_name == :right
374
+ line.right
375
+ elsif key_name == :home
376
+ line.move_to_start
377
+ elsif key_name == :end
378
+ line.move_to_end
379
+ else
380
+ if raw && [CARRIAGE_RETURN, NEWLINE].include?(code)
381
+ char = "\n"
382
+ line.move_to_end
383
+ end
384
+ line.insert(char)
385
+ buffer = line.text unless history_in_use
386
+ end
387
+
388
+ if (key_name == :backspace || code == BACKSPACE) && echo
389
+ if raw
390
+ output.print("\e[1X") unless line.start?
391
+ else
392
+ output.print(?\s + (line.start? ? "" : ?\b))
393
+ end
394
+ end
395
+
396
+ previous_key_name = key_name
397
+
398
+ # trigger before line is printed to allow for line changes
399
+ trigger_key_event(char, line: line)
400
+
401
+ if raw && echo
402
+ output.print(line.to_s)
403
+ if char == "\n"
404
+ line.move_to_start
405
+ elsif !line.end? # readjust cursor position
406
+ output.print(cursor.backward(line.text_size - line.cursor))
407
+ end
408
+ end
409
+
410
+ if [CARRIAGE_RETURN, NEWLINE].include?(code)
411
+ buffer = ""
412
+ output.puts unless echo
413
+ break
414
+ end
415
+ end
416
+
417
+ if track_history? && echo
418
+ add_to_history(line.text.rstrip)
419
+ end
420
+
421
+ line.text
422
+ end
423
+
424
+ # Clear display for the current line input
425
+ #
426
+ # Handles clearing input that is longer than the current
427
+ # terminal width which allows copy & pasting long strings.
428
+ #
429
+ # @param [Line] line
430
+ # the line to display
431
+ # @param [Number] screen_width
432
+ # the terminal screen width
433
+ #
434
+ # @api private
435
+ def clear_display(line, screen_width)
436
+ total_lines = count_screen_lines(line.size, screen_width)
437
+ current_line = count_screen_lines(line.prompt_size + line.cursor, screen_width)
438
+ lines_down = total_lines - current_line
439
+
440
+ output.print(cursor.down(lines_down)) unless lines_down.zero?
441
+ output.print(cursor.clear_lines(total_lines))
442
+ end
443
+
444
+ # Count the number of screen lines given line takes up in terminal
445
+ #
446
+ # @param [Integer] line_or_size
447
+ # the current line or its length
448
+ # @param [Integer] screen_width
449
+ # the width of terminal screen
450
+ #
451
+ # @return [Integer]
452
+ #
453
+ # @api public
454
+ def count_screen_lines(line_or_size, screen_width = TTY::Screen.width)
455
+ line_size = if line_or_size.is_a?(Integer)
456
+ line_or_size
457
+ else
458
+ Line.sanitize(line_or_size).size
459
+ end
460
+ # new character + we don't want to add new line on screen_width
461
+ new_chars = self.class.windows? ? -1 : 1
462
+ 1 + [0, (line_size - new_chars) / screen_width].max
463
+ end
464
+
465
+ # Read multiple lines and return them in an array.
466
+ # Skip empty lines in the returned lines array.
467
+ # The input gathering is terminated by Ctrl+d or Ctrl+z.
468
+ #
469
+ # @param [String] prompt
470
+ # the prompt displayed before the input
471
+ # @param [String] value
472
+ # the value to pre-populate line with
473
+ # @param [Boolean] echo
474
+ # whether to echo chars back or not, defaults to false
475
+ # @param [Array<Symbol>] exit_keys
476
+ # the custom keys to exit line editing
477
+ # @option [Boolean] raw
478
+ # whenther raw mode is enabled, defaults to true
479
+ # @option [Boolean] nonblock
480
+ # whether to wait for input or not, defaults to false
481
+ #
482
+ # @yield [String] line
483
+ #
484
+ # @return [Array[String]]
485
+ #
486
+ # @api public
487
+ def read_multiline(prompt = "", value: "", echo: true, raw: true,
488
+ nonblock: false, exit_keys: EXIT_KEYS)
489
+ lines = []
490
+ stop = false
491
+ clear_value = !value.to_s.empty?
492
+
493
+ loop do
494
+ line = read_line(prompt, value: value, echo: echo, raw: raw,
495
+ nonblock: nonblock, exit_keys: exit_keys).to_s
496
+ if clear_value
497
+ clear_value = false
498
+ value = ""
499
+ end
500
+ break if line.empty?
501
+
502
+ stop = line.match(END_WITH_LINE_BREAK).nil?
503
+ next if line !~ /\S/ && !stop
504
+
505
+ if block_given?
506
+ yield(line)
507
+ else
508
+ lines << line
509
+ end
510
+ break if stop
511
+ end
512
+
513
+ lines
514
+ end
515
+ alias read_lines read_multiline
516
+
517
+ # Expose event broadcasting
518
+ #
519
+ # @api public
520
+ def trigger(event, *args)
521
+ publish(event, *args)
522
+ end
523
+
524
+ # Add a line to history
525
+ #
526
+ # @param [String] line
527
+ #
528
+ # @api private
529
+ def add_to_history(line)
530
+ @history.push(line)
531
+ end
532
+
533
+ # Check if history has next line
534
+ #
535
+ # @param [Boolean]
536
+ #
537
+ # @api private
538
+ def history_next?
539
+ @history.next?
540
+ end
541
+
542
+ # Move history to the next line
543
+ #
544
+ # @return [String]
545
+ # the next line
546
+ #
547
+ # @api private
548
+ def history_next
549
+ @history.next
550
+ @history.get
551
+ end
552
+
553
+ # Check if history has previous line
554
+ #
555
+ # @return [Boolean]
556
+ #
557
+ # @api private
558
+ def history_previous?
559
+ @history.previous?
560
+ end
561
+
562
+ # Move history to the previous line
563
+ #
564
+ # @param [Boolean] skip
565
+ # whether or not to move history index
566
+ #
567
+ # @return [String]
568
+ # the previous line
569
+ #
570
+ # @api private
571
+ def history_previous(skip: false)
572
+ @history.previous unless skip
573
+ @history.get
574
+ end
575
+
576
+ # Inspect class name and public attributes
577
+ #
578
+ # @return [String]
579
+ #
580
+ # @api public
581
+ def inspect
582
+ "#<#{self.class}: @input=#{input}, @output=#{output}>"
583
+ end
584
+
585
+ private
586
+
587
+ # Trigger completion event
588
+ #
589
+ # @param [String] completion
590
+ # the suggested word completion
591
+ # @param [Line] line
592
+ # the line with word to complete
593
+ #
594
+ # @api private
595
+ def trigger_completion_event(completion, line)
596
+ completion_event = CompletionEvent.new(@completer, completion, line)
597
+ trigger(:complete, completion_event)
598
+ end
599
+
600
+ # Publish event
601
+ #
602
+ # @param [String] char
603
+ # the key pressed
604
+ #
605
+ # @return [nil]
606
+ #
607
+ # @api private
608
+ def trigger_key_event(char, line: Line.new)
609
+ event = KeyEvent.from(console.keys, char, line)
610
+ trigger(:"key#{event.key.name}", event) if event.trigger?
611
+ trigger(:keypress, event)
612
+ end
613
+
614
+ # Handle input interrupt based on provided value
615
+ #
616
+ # @api private
617
+ def handle_interrupt
618
+ case @interrupt
619
+ when :signal
620
+ Process.kill("SIGINT", Process.pid)
621
+ when :exit
622
+ exit(130)
623
+ when Proc
624
+ @interrupt.call
625
+ when :noop
626
+ # Noop
627
+ else
628
+ raise InputInterrupt
629
+ end
630
+ end
631
+ end # Reader
632
+ end # TTY2
@@ -0,0 +1 @@
1
+ require_relative 'tty2/reader'