File: reader.rb

package info (click to toggle)
ruby-tty-reader 0.9.0-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, forky, sid, trixie
  • size: 772 kB
  • sloc: ruby: 1,759; sh: 4; makefile: 4
file content (498 lines) | stat: -rw-r--r-- 13,238 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
# frozen_string_literal: true

require "tty-cursor"
require "tty-screen"
require "wisper"

require_relative "reader/history"
require_relative "reader/line"
require_relative "reader/key_event"
require_relative "reader/console"
require_relative "reader/win_console"
require_relative "reader/version"

module TTY
  # A class responsible for reading character input from STDIN
  #
  # Used internally to provide key and line reading functionality
  #
  # @api public
  class Reader
    include Wisper::Publisher

    # Key codes
    CARRIAGE_RETURN = 13
    NEWLINE         = 10
    BACKSPACE       = 8
    DELETE          = 127

    # Keys that terminate input
    EXIT_KEYS = [:ctrl_d, :ctrl_z]

    # Raised when the user hits the interrupt key(Control-C)
    #
    # @api public
    InputInterrupt = Class.new(Interrupt)

    # Check if Windowz mode
    #
    # @return [Boolean]
    #
    # @api public
    def self.windows?
      ::File::ALT_SEPARATOR == "\\"
    end

    attr_reader :input

    attr_reader :output

    attr_reader :env

    attr_reader :track_history
    alias track_history? track_history

    attr_reader :console

    attr_reader :cursor

    # Initialize a Reader
    #
    # @param [IO] input
    #   the input stream
    # @param [IO] output
    #   the output stream
    # @param [Symbol] interrupt
    #   the way to handle the Ctrl+C key out of :signal, :exit, :noop
    # @param [Hash] env
    #   the environment variables
    # @param [Boolean] track_history
    #   disable line history tracking, true by default
    # @param [Boolean] history_cycle
    #   allow cycling through history, false by default
    # @param [Boolean] history_duplicates
    #   allow duplicate entires, false by default
    # @param [Proc] history_exclude
    #   exclude lines from history, by default all lines are stored
    #
    # @api public
    def initialize(input: $stdin, output: $stdout, interrupt: :error,
                   env: ENV, track_history: true, history_cycle: false,
                   history_exclude: History::DEFAULT_EXCLUDE,
                   history_duplicates: false)
      @input = input
      @output = output
      @interrupt = interrupt
      @env = env
      @track_history = track_history
      @history_cycle = history_cycle
      @history_exclude = history_exclude
      @history_duplicates = history_duplicates

      @console = select_console(input)
      @history = History.new do |h|
        h.cycle = history_cycle
        h.duplicates = history_duplicates
        h.exclude = history_exclude
      end
      @stop = false # gathering input
      @cursor = TTY::Cursor

      subscribe(self)
    end

    alias old_subcribe subscribe

    # Subscribe to receive key events
    #
    # @example
    #   reader.subscribe(MyListener.new)
    #
    # @return [self|yield]
    #
    # @api public
    def subscribe(listener, options = {})
      old_subcribe(listener, options)
      object = self
      if block_given?
        object = yield
        unsubscribe(listener)
      end
      object
    end

    # Unsubscribe from receiving key events
    #
    # @example
    #   reader.unsubscribe(my_listener)
    #
    # @return [void]
    #
    # @api public
    def unsubscribe(listener)
      registry = send(:local_registrations)
      registry.each do |object|
        if object.listener.equal?(listener)
          registry.delete(object)
        end
      end
    end

    # Select appropriate console
    #
    # @api private
    def select_console(input)
      if self.class.windows? && !env["TTY_TEST"]
        WinConsole.new(input)
      else
        Console.new(input)
      end
    end

    # Get input in unbuffered mode.
    #
    # @example
    #   unbufferred do
    #     ...
    #   end
    #
    # @api public
    def unbufferred(&block)
      bufferring = output.sync
      # Immediately flush output
      output.sync = true
      block[] if block_given?
    ensure
      output.sync = bufferring
    end

    # Read a keypress including invisible multibyte codes and return
    # a character as a string.
    # Nothing is echoed to the console. This call will block for a
    # single keypress, but will not wait for Enter to be pressed.
    #
    # @param [Boolean] echo
    #   whether to echo chars back or not, defaults to false
    # @option [Boolean] raw
    #   whenther raw mode is enabled, defaults to true
    # @option [Boolean] nonblock
    #   whether to wait for input or not, defaults to false
    #
    # @return [String]
    #
    # @api public
    def read_keypress(echo: false, raw: true, nonblock: false)
      codes = unbufferred do
        get_codes(echo: echo, raw: raw, nonblock: nonblock)
      end
      char = codes ? codes.pack("U*") : nil

      trigger_key_event(char) if char
      char
    end
    alias read_char read_keypress

    # Get input code points
    #
    # @param [Boolean] echo
    #   whether to echo chars back or not, defaults to false
    # @option [Boolean] raw
    #   whenther raw mode is enabled, defaults to true
    # @option [Boolean] nonblock
    #   whether to wait for input or not, defaults to false
    # @param [Array[Integer]] codes
    #   the currently read char code points
    #
    # @return [Array[Integer]]
    #
    # @api private
    def get_codes(echo: true, raw: false, nonblock: false, codes: [])
      char = console.get_char(echo: echo, raw: raw, nonblock: nonblock)
      handle_interrupt if console.keys[char] == :ctrl_c
      return if char.nil?

      codes << char.ord
      condition = proc { |escape|
        (codes - escape).empty? ||
        (escape - codes).empty? &&
        !(64..126).cover?(codes.last)
      }

      while console.escape_codes.any?(&condition)
        char_codes = get_codes(echo: echo, raw: raw,
                               nonblock: true, codes: codes)
        break if char_codes.nil?
      end

      codes
    end

    # Get a single line from STDIN. Each key pressed is echoed
    # back to the shell. The input terminates when enter or
    # return key is pressed.
    #
    # @param [String] prompt
    #   the prompt to display before input
    # @param [String] value
    #   the value to pre-populate line with
    # @param [Boolean] echo
    #   whether to echo chars back or not, defaults to false
    # @option [Boolean] raw
    #   whenther raw mode is enabled, defaults to true
    # @option [Boolean] nonblock
    #   whether to wait for input or not, defaults to false
    #
    # @return [String]
    #
    # @api public
    def read_line(prompt = "", value: "", echo: true, raw: true, nonblock: false)
      line = Line.new(value, prompt: prompt)
      screen_width = TTY::Screen.width
      buffer = ""

      output.print(line)

      while (codes = get_codes(echo: echo, raw: raw, nonblock: nonblock)) &&
            (code = codes[0])
        char = codes.pack("U*")

        if EXIT_KEYS.include?(console.keys[char])
          trigger_key_event(char, line: line.to_s)
          break
        end

        if raw && echo
          clear_display(line, screen_width)
        end

        if console.keys[char] == :backspace || code == BACKSPACE
          if !line.start?
            line.left
            line.delete
          end
        elsif console.keys[char] == :delete || code == DELETE
          line.delete
        elsif console.keys[char].to_s =~ /ctrl_/
          # skip
        elsif console.keys[char] == :up
          line.replace(history_previous) if history_previous?
        elsif console.keys[char] == :down
          line.replace(history_next? ? history_next : buffer) if track_history?
        elsif console.keys[char] == :left
          line.left
        elsif console.keys[char] == :right
          line.right
        elsif console.keys[char] == :home
          line.move_to_start
        elsif console.keys[char] == :end
          line.move_to_end
        else
          if raw && code == CARRIAGE_RETURN
            char = "\n"
            line.move_to_end
          end
          line.insert(char)
          buffer = line.text
        end

        if (console.keys[char] == :backspace || code == BACKSPACE) && echo
          if raw
            output.print("\e[1X") unless line.start?
          else
            output.print(?\s + (line.start? ? "" : ?\b))
          end
        end

        # trigger before line is printed to allow for line changes
        trigger_key_event(char, line: line.to_s)

        if raw && echo
          output.print(line.to_s)
          if char == "\n"
            line.move_to_start
          elsif !line.end? # readjust cursor position
            output.print(cursor.backward(line.text_size - line.cursor))
          end
        end

        if [CARRIAGE_RETURN, NEWLINE].include?(code)
          buffer = ""
          output.puts unless echo
          break
        end
      end

      if track_history? && echo
        add_to_history(line.text.rstrip)
      end

      line.text
    end

    # Clear display for the current line input
    #
    # Handles clearing input that is longer than the current
    # terminal width which allows copy & pasting long strings.
    #
    # @param [Line] line
    #   the line to display
    # @param [Number] screen_width
    #   the terminal screen width
    #
    # @api private
    def clear_display(line, screen_width)
      total_lines  = count_screen_lines(line.size, screen_width)
      current_line = count_screen_lines(line.prompt_size + line.cursor, screen_width)
      lines_down = total_lines - current_line

      output.print(cursor.down(lines_down)) unless lines_down.zero?
      output.print(cursor.clear_lines(total_lines))
    end

    # Count the number of screen lines given line takes up in terminal
    #
    # @param [Integer] line_or_size
    #   the current line or its length
    # @param [Integer] screen_width
    #   the width of terminal screen
    #
    # @return [Integer]
    #
    # @api public
    def count_screen_lines(line_or_size, screen_width = TTY::Screen.width)
      line_size = if line_or_size.is_a?(Integer)
                    line_or_size
                  else
                    Line.sanitize(line_or_size).size
                  end
      # new character + we don't want to add new line on screen_width
      new_chars = self.class.windows? ? -1 : 1
      1 + [0, (line_size - new_chars) / screen_width].max
    end

    # Read multiple lines and return them in an array.
    # Skip empty lines in the returned lines array.
    # The input gathering is terminated by Ctrl+d or Ctrl+z.
    #
    # @param [String] prompt
    #   the prompt displayed before the input
    # @param [String] value
    #   the value to pre-populate line with
    # @param [Boolean] echo
    #   whether to echo chars back or not, defaults to false
    # @option [Boolean] raw
    #   whenther raw mode is enabled, defaults to true
    # @option [Boolean] nonblock
    #   whether to wait for input or not, defaults to false
    #
    # @yield [String] line
    #
    # @return [Array[String]]
    #
    # @api public
    def read_multiline(prompt = "", value: "", echo: true, raw: true,
                       nonblock: false)
      @stop = false
      lines = []
      empty_str = ""

      loop do
        line = read_line(prompt, value: value, echo: echo, raw: raw,
                                 nonblock: nonblock)
        value = empty_str unless value.empty? # reset
        break if !line || line == empty_str
        next  if line !~ /\S/ && !@stop

        if block_given?
          yield(line) unless line.to_s.empty?
        else
          lines << line unless line.to_s.empty?
        end
        break if @stop
      end

      lines
    end
    alias read_lines read_multiline

    # Expose event broadcasting
    #
    # @api public
    def trigger(event, *args)
      publish(event, *args)
    end

    # Capture Ctrl+d and Ctrl+z key events
    #
    # @api private
    def keyctrl_d(*)
      @stop = true
    end
    alias keyctrl_z keyctrl_d

    def add_to_history(line)
      @history.push(line)
    end

    def history_next?
      @history.next?
    end

    def history_next
      @history.next
      @history.get
    end

    def history_previous?
      @history.previous?
    end

    def history_previous
      line = @history.get
      @history.previous
      line
    end

    # Inspect class name and public attributes
    # @return [String]
    #
    # @api public
    def inspect
      "#<#{self.class}: @input=#{input}, @output=#{output}>"
    end

    private

    # Publish event
    #
    # @param [String] char
    #   the key pressed
    #
    # @return [nil]
    #
    # @api private
    def trigger_key_event(char, line: "")
      event = KeyEvent.from(console.keys, char, line)
      trigger(:"key#{event.key.name}", event) if event.trigger?
      trigger(:keypress, event)
    end

    # Handle input interrupt based on provided value
    #
    # @api private
    def handle_interrupt
      case @interrupt
      when :signal
        Process.kill("SIGINT", Process.pid)
      when :exit
        exit(130)
      when Proc
        @interrupt.call
      when :noop
        # Noop
      else
        raise InputInterrupt
      end
    end
  end # Reader
end # TTY