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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +120 -0
- data/lib/tty2/reader/completer.rb +188 -0
- data/lib/tty2/reader/completion_event.rb +36 -0
- data/lib/tty2/reader/completions.rb +107 -0
- data/lib/tty2/reader/console.rb +68 -0
- data/lib/tty2/reader/history.rb +184 -0
- data/lib/tty2/reader/key_event.rb +58 -0
- data/lib/tty2/reader/keys.rb +166 -0
- data/lib/tty2/reader/line.rb +367 -0
- data/lib/tty2/reader/mode.rb +42 -0
- data/lib/tty2/reader/version.rb +7 -0
- data/lib/tty2/reader/win_api.rb +51 -0
- data/lib/tty2/reader/win_console.rb +90 -0
- data/lib/tty2/reader.rb +632 -0
- data/lib/tty2-reader.rb +1 -0
- metadata +143 -0
@@ -0,0 +1,367 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module TTY2
|
6
|
+
class Reader
|
7
|
+
class Line
|
8
|
+
ANSI_MATCHER = /(\[)?\033(\[)?[;?\d]*[\dA-Za-z](\])?/
|
9
|
+
|
10
|
+
# The word break characters list used by shell
|
11
|
+
DEFAULT_WORD_BREAK_CHARACTERS = " \t\n\"\\'`@$><=|&{("
|
12
|
+
|
13
|
+
# Strip ANSI characters from the text
|
14
|
+
#
|
15
|
+
# @param [String] text
|
16
|
+
#
|
17
|
+
# @return [String]
|
18
|
+
#
|
19
|
+
# @api public
|
20
|
+
def self.sanitize(text)
|
21
|
+
text.dup.gsub(ANSI_MATCHER, "")
|
22
|
+
end
|
23
|
+
|
24
|
+
# The editable text
|
25
|
+
# @api public
|
26
|
+
attr_reader :text
|
27
|
+
|
28
|
+
# The current cursor position witin the text
|
29
|
+
# @api public
|
30
|
+
attr_reader :cursor
|
31
|
+
|
32
|
+
# The line mode
|
33
|
+
# @api public
|
34
|
+
attr_reader :mode
|
35
|
+
|
36
|
+
# The prompt displayed before input
|
37
|
+
# @api public
|
38
|
+
attr_reader :prompt
|
39
|
+
|
40
|
+
# The prompt size
|
41
|
+
# @api public
|
42
|
+
attr_reader :prompt_size
|
43
|
+
|
44
|
+
# The word separator pattern for splitting the text
|
45
|
+
#
|
46
|
+
# @return [Regexp]
|
47
|
+
#
|
48
|
+
# @api public
|
49
|
+
attr_reader :separator
|
50
|
+
|
51
|
+
# Create a Line instance
|
52
|
+
#
|
53
|
+
# @api private
|
54
|
+
def initialize(text = "", prompt: "", separator: nil)
|
55
|
+
@text = text.dup
|
56
|
+
@prompt = prompt.dup
|
57
|
+
@prompt_size = prompt_display_width
|
58
|
+
break_chars = DEFAULT_WORD_BREAK_CHARACTERS.chars
|
59
|
+
@separator = separator || Regexp.union(break_chars)
|
60
|
+
@cursor = [0, @text.length].max
|
61
|
+
@mode = :edit
|
62
|
+
|
63
|
+
yield self if block_given?
|
64
|
+
end
|
65
|
+
|
66
|
+
# Prompt display width
|
67
|
+
#
|
68
|
+
# @return [Integer]
|
69
|
+
# the prompt size
|
70
|
+
#
|
71
|
+
# @api public
|
72
|
+
def prompt_display_width
|
73
|
+
lines = self.class.sanitize(@prompt).split(/\r?\n/)
|
74
|
+
return 0 if lines.empty?
|
75
|
+
|
76
|
+
# return the length of each line + screen width for every line
|
77
|
+
# past the first which accounts for multi-line prompts
|
78
|
+
lines.join.length + ((lines.length - 1) * TTY::Screen.width)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Check if line is in edit mode
|
82
|
+
#
|
83
|
+
# @return [Boolean]
|
84
|
+
#
|
85
|
+
# @public
|
86
|
+
def editing?
|
87
|
+
@mode == :edit
|
88
|
+
end
|
89
|
+
|
90
|
+
# Enable edit mode
|
91
|
+
#
|
92
|
+
# @return [Boolean]
|
93
|
+
#
|
94
|
+
# @public
|
95
|
+
def edit_mode
|
96
|
+
@mode = :edit
|
97
|
+
end
|
98
|
+
|
99
|
+
# Check if line is in replace mode
|
100
|
+
#
|
101
|
+
# @return [Boolean]
|
102
|
+
#
|
103
|
+
# @public
|
104
|
+
def replacing?
|
105
|
+
@mode == :replace
|
106
|
+
end
|
107
|
+
|
108
|
+
# Enable replace mode
|
109
|
+
#
|
110
|
+
# @return [Boolean]
|
111
|
+
#
|
112
|
+
# @public
|
113
|
+
def replace_mode
|
114
|
+
@mode = :replace
|
115
|
+
end
|
116
|
+
|
117
|
+
# Check if cursor reached beginning of the line
|
118
|
+
#
|
119
|
+
# @return [Boolean]
|
120
|
+
#
|
121
|
+
# @api public
|
122
|
+
def start?
|
123
|
+
@cursor.zero?
|
124
|
+
end
|
125
|
+
|
126
|
+
# Check if cursor reached end of the line
|
127
|
+
#
|
128
|
+
# @return [Boolean]
|
129
|
+
#
|
130
|
+
# @api public
|
131
|
+
def end?
|
132
|
+
@cursor == @text.length
|
133
|
+
end
|
134
|
+
|
135
|
+
# Move line position to the left by n chars
|
136
|
+
#
|
137
|
+
# @api public
|
138
|
+
def left(n = 1)
|
139
|
+
@cursor = [0, @cursor - n].max
|
140
|
+
end
|
141
|
+
|
142
|
+
# Move line position to the right by n chars
|
143
|
+
#
|
144
|
+
# @api public
|
145
|
+
def right(n = 1)
|
146
|
+
@cursor = [@text.length, @cursor + n].min
|
147
|
+
end
|
148
|
+
|
149
|
+
# Move cursor to beginning position
|
150
|
+
#
|
151
|
+
# @api public
|
152
|
+
def move_to_start
|
153
|
+
@cursor = 0
|
154
|
+
end
|
155
|
+
|
156
|
+
# Move cursor to end position
|
157
|
+
#
|
158
|
+
# @api public
|
159
|
+
def move_to_end
|
160
|
+
@cursor = @text.length # put cursor outside of text
|
161
|
+
end
|
162
|
+
|
163
|
+
# Insert characters inside a line. When the lines exceeds
|
164
|
+
# maximum length, an extra space is added to accomodate index.
|
165
|
+
#
|
166
|
+
# @param [Integer] i
|
167
|
+
# the index to insert at
|
168
|
+
#
|
169
|
+
# @param [String] chars
|
170
|
+
# the characters to insert
|
171
|
+
#
|
172
|
+
# @example
|
173
|
+
# text = "aaa"
|
174
|
+
# line[5]= "b"
|
175
|
+
# => "aaa b"
|
176
|
+
#
|
177
|
+
# @api public
|
178
|
+
def []=(i, chars)
|
179
|
+
edit_mode
|
180
|
+
|
181
|
+
if i.is_a?(Range)
|
182
|
+
@text[i] = chars
|
183
|
+
@cursor += chars.length
|
184
|
+
return
|
185
|
+
end
|
186
|
+
|
187
|
+
if i <= 0
|
188
|
+
before_text = ""
|
189
|
+
after_text = @text.dup
|
190
|
+
elsif i > @text.length - 1 # insert outside of line input
|
191
|
+
before_text = @text.dup
|
192
|
+
after_text = ?\s * (i - @text.length)
|
193
|
+
@cursor += after_text.length
|
194
|
+
else
|
195
|
+
before_text = @text[0..i-1].dup
|
196
|
+
after_text = @text[i..-1].dup
|
197
|
+
end
|
198
|
+
|
199
|
+
if i > @text.length - 1
|
200
|
+
@text = before_text + after_text + chars
|
201
|
+
else
|
202
|
+
@text = before_text + chars + after_text
|
203
|
+
end
|
204
|
+
|
205
|
+
@cursor = i + chars.length
|
206
|
+
end
|
207
|
+
|
208
|
+
# Read character
|
209
|
+
#
|
210
|
+
# @api public
|
211
|
+
def [](i)
|
212
|
+
@text[i]
|
213
|
+
end
|
214
|
+
|
215
|
+
# Find a word under the cursor based on a separator
|
216
|
+
#
|
217
|
+
# @param [Boolean] before
|
218
|
+
# whether to start searching before or after a break character
|
219
|
+
#
|
220
|
+
# @return [String]
|
221
|
+
#
|
222
|
+
# @api public
|
223
|
+
def word(before: true)
|
224
|
+
start_pos = word_start_pos(before: before)
|
225
|
+
@text[start_pos..word_end_pos(from: start_pos)]
|
226
|
+
end
|
227
|
+
|
228
|
+
# Find a subtext under the cursor
|
229
|
+
#
|
230
|
+
# @param [Boolean] before
|
231
|
+
# whether to return the subtext before or after the cursor position
|
232
|
+
#
|
233
|
+
# @return [String]
|
234
|
+
#
|
235
|
+
# @api public
|
236
|
+
def subtext(before: true)
|
237
|
+
|
238
|
+
before ? @text[0..@cursor] : @text[@cursor..-1]
|
239
|
+
|
240
|
+
end
|
241
|
+
|
242
|
+
# Find a word up to a cursor position
|
243
|
+
#
|
244
|
+
# @param [Boolean] before
|
245
|
+
# whether to start searching before or after a break character
|
246
|
+
#
|
247
|
+
# @return [String]
|
248
|
+
#
|
249
|
+
# @api public
|
250
|
+
def word_to_complete(before: true)
|
251
|
+
@text[word_start_pos(before: before)...@cursor]
|
252
|
+
end
|
253
|
+
|
254
|
+
# Find word start position
|
255
|
+
#
|
256
|
+
# @param [Integer] from
|
257
|
+
# the index to start searching from, defaults to cursor position
|
258
|
+
#
|
259
|
+
# @param [Symbol] before
|
260
|
+
# whether to start search before or after break character
|
261
|
+
#
|
262
|
+
# @return [Integer]
|
263
|
+
#
|
264
|
+
# @api public
|
265
|
+
def word_start_pos(from: @cursor, before: true)
|
266
|
+
# move back or forward by one character when at a word boundary
|
267
|
+
if word_boundary?
|
268
|
+
from = before ? from - 1 : from + 1
|
269
|
+
end
|
270
|
+
|
271
|
+
start_pos = @text.rindex(separator, from) || 0
|
272
|
+
start_pos += 1 unless start_pos.zero?
|
273
|
+
start_pos
|
274
|
+
end
|
275
|
+
|
276
|
+
# Find word end position
|
277
|
+
#
|
278
|
+
# @param [Integer] from
|
279
|
+
# the index to start searching from, defaults to cursor position
|
280
|
+
#
|
281
|
+
# @return [Integer]
|
282
|
+
#
|
283
|
+
# @api public
|
284
|
+
def word_end_pos(from: @cursor)
|
285
|
+
end_pos = @text.index(separator, from) || text_size
|
286
|
+
end_pos -= 1 unless @text.empty?
|
287
|
+
end_pos
|
288
|
+
end
|
289
|
+
|
290
|
+
# Check if cursor is at a word boundary
|
291
|
+
#
|
292
|
+
# @return [Boolean]
|
293
|
+
#
|
294
|
+
# @api private
|
295
|
+
def word_boundary?
|
296
|
+
@text[@cursor] =~ separator
|
297
|
+
end
|
298
|
+
|
299
|
+
# Replace current line with new text
|
300
|
+
#
|
301
|
+
# @param [String] text
|
302
|
+
#
|
303
|
+
# @api public
|
304
|
+
def replace(text)
|
305
|
+
@text = text
|
306
|
+
@cursor = @text.length # put cursor outside of text
|
307
|
+
replace_mode
|
308
|
+
end
|
309
|
+
|
310
|
+
# Insert char(s) at cursor position
|
311
|
+
#
|
312
|
+
# @api public
|
313
|
+
def insert(chars)
|
314
|
+
self[@cursor] = chars
|
315
|
+
end
|
316
|
+
|
317
|
+
# Add char and move cursor
|
318
|
+
#
|
319
|
+
# @api public
|
320
|
+
def <<(char)
|
321
|
+
@text << char
|
322
|
+
@cursor += 1
|
323
|
+
end
|
324
|
+
|
325
|
+
# Remove char from the line at current position
|
326
|
+
#
|
327
|
+
# @api public
|
328
|
+
def delete(n = 1)
|
329
|
+
@text.slice!(@cursor, n)
|
330
|
+
end
|
331
|
+
|
332
|
+
# Remove char from the line in front of the cursor
|
333
|
+
#
|
334
|
+
# @param [Integer] n
|
335
|
+
# the number of chars to remove
|
336
|
+
#
|
337
|
+
# @api public
|
338
|
+
def remove(n = 1)
|
339
|
+
left(n)
|
340
|
+
@text.slice!(@cursor, n)
|
341
|
+
end
|
342
|
+
|
343
|
+
# Full line with prompt as string
|
344
|
+
#
|
345
|
+
# @api public
|
346
|
+
def to_s
|
347
|
+
"#{@prompt}#{@text}"
|
348
|
+
end
|
349
|
+
alias inspect to_s
|
350
|
+
|
351
|
+
# Text size
|
352
|
+
#
|
353
|
+
# @api public
|
354
|
+
def text_size
|
355
|
+
self.class.sanitize(@text).size
|
356
|
+
end
|
357
|
+
|
358
|
+
# Full line size with prompt
|
359
|
+
#
|
360
|
+
# @api public
|
361
|
+
def size
|
362
|
+
prompt_size + text_size
|
363
|
+
end
|
364
|
+
alias length size
|
365
|
+
end # Line
|
366
|
+
end # Reader
|
367
|
+
end # TTY2
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "io/console"
|
4
|
+
|
5
|
+
module TTY2
|
6
|
+
class Reader
|
7
|
+
class Mode
|
8
|
+
# Initialize a Terminal
|
9
|
+
#
|
10
|
+
# @api public
|
11
|
+
def initialize(input = $stdin)
|
12
|
+
@input = input
|
13
|
+
end
|
14
|
+
|
15
|
+
# Echo given block
|
16
|
+
#
|
17
|
+
# @param [Boolean] is_on
|
18
|
+
#
|
19
|
+
# @api public
|
20
|
+
def echo(is_on = true, &block)
|
21
|
+
if is_on || !@input.tty?
|
22
|
+
yield
|
23
|
+
else
|
24
|
+
@input.noecho(&block)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Use raw mode in the given block
|
29
|
+
#
|
30
|
+
# @param [Boolean] is_on
|
31
|
+
#
|
32
|
+
# @api public
|
33
|
+
def raw(is_on = true, &block)
|
34
|
+
if is_on && @input.tty?
|
35
|
+
@input.raw(&block)
|
36
|
+
else
|
37
|
+
yield
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end # Mode
|
41
|
+
end # Reader
|
42
|
+
end # TTY2
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fiddle"
|
4
|
+
|
5
|
+
module TTY2
|
6
|
+
class Reader
|
7
|
+
module WinAPI
|
8
|
+
include Fiddle
|
9
|
+
|
10
|
+
CRT_HANDLE = Fiddle::Handle.new("msvcrt") rescue Fiddle::Handle.new("crtdll")
|
11
|
+
|
12
|
+
# Get a character from the console without echo.
|
13
|
+
#
|
14
|
+
# @return [String]
|
15
|
+
# return the character read
|
16
|
+
#
|
17
|
+
# @api public
|
18
|
+
def getch
|
19
|
+
@@getch ||= Fiddle::Function.new(CRT_HANDLE["_getch"], [], TYPE_INT)
|
20
|
+
@@getch.call
|
21
|
+
end
|
22
|
+
module_function :getch
|
23
|
+
|
24
|
+
# Gets a character from the console with echo.
|
25
|
+
#
|
26
|
+
# @return [String]
|
27
|
+
# return the character read
|
28
|
+
#
|
29
|
+
# @api public
|
30
|
+
def getche
|
31
|
+
@@getche ||= Fiddle::Function.new(CRT_HANDLE["_getche"], [], TYPE_INT)
|
32
|
+
@@getche.call
|
33
|
+
end
|
34
|
+
module_function :getche
|
35
|
+
|
36
|
+
# Check the console for recent keystroke. If the function
|
37
|
+
# returns a nonzero value, a keystroke is waiting in the buffer.
|
38
|
+
#
|
39
|
+
# @return [Integer]
|
40
|
+
# return a nonzero value if a key has been pressed. Otherwirse,
|
41
|
+
# it returns 0.
|
42
|
+
#
|
43
|
+
# @api public
|
44
|
+
def kbhit
|
45
|
+
@@kbhit ||= Fiddle::Function.new(CRT_HANDLE["_kbhit"], [], TYPE_INT)
|
46
|
+
@@kbhit.call
|
47
|
+
end
|
48
|
+
module_function :kbhit
|
49
|
+
end # WinAPI
|
50
|
+
end # Reader
|
51
|
+
end # TTY2
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "keys"
|
4
|
+
|
5
|
+
module TTY2
|
6
|
+
class Reader
|
7
|
+
class WinConsole
|
8
|
+
ESC = "\e"
|
9
|
+
NUL_HEX = "\x00"
|
10
|
+
EXT_HEX = "\xE0"
|
11
|
+
|
12
|
+
# Key codes
|
13
|
+
#
|
14
|
+
# @return [Hash[Symbol]]
|
15
|
+
#
|
16
|
+
# @api public
|
17
|
+
attr_reader :keys
|
18
|
+
|
19
|
+
# Escape codes
|
20
|
+
#
|
21
|
+
# @return [Array[Integer]]
|
22
|
+
#
|
23
|
+
# @api public
|
24
|
+
attr_reader :escape_codes
|
25
|
+
|
26
|
+
def initialize(input)
|
27
|
+
require_relative "win_api"
|
28
|
+
@input = input
|
29
|
+
@keys = Keys.ctrl_keys.merge(Keys.win_keys)
|
30
|
+
@escape_codes = [[NUL_HEX.ord], [ESC.ord], EXT_HEX.bytes.to_a]
|
31
|
+
end
|
32
|
+
|
33
|
+
# Get a character from console blocking for input
|
34
|
+
#
|
35
|
+
# @param [Boolean] echo
|
36
|
+
# whether to echo input back or not, defaults to true
|
37
|
+
# @param [Boolean] raw
|
38
|
+
# whether to use raw mode or not, defaults to false
|
39
|
+
# @param [Boolean] nonblock
|
40
|
+
# whether to wait for input or not, defaults to false
|
41
|
+
#
|
42
|
+
# @return [String]
|
43
|
+
#
|
44
|
+
# @api private
|
45
|
+
def get_char(echo: true, raw: false, nonblock: false)
|
46
|
+
if raw && echo
|
47
|
+
if nonblock
|
48
|
+
get_char_echo_non_blocking
|
49
|
+
else
|
50
|
+
get_char_echo_blocking
|
51
|
+
end
|
52
|
+
elsif raw && !echo
|
53
|
+
nonblock ? get_char_non_blocking : get_char_blocking
|
54
|
+
elsif !raw && !echo
|
55
|
+
nonblock ? get_char_non_blocking : get_char_blocking
|
56
|
+
else
|
57
|
+
@input.getc
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Get the char for last key pressed, or if no keypress return nil
|
62
|
+
#
|
63
|
+
# @api private
|
64
|
+
def get_char_non_blocking
|
65
|
+
input_ready? ? get_char_blocking : nil
|
66
|
+
end
|
67
|
+
|
68
|
+
def get_char_echo_non_blocking
|
69
|
+
input_ready? ? get_char_echo_blocking : nil
|
70
|
+
end
|
71
|
+
|
72
|
+
def get_char_blocking
|
73
|
+
WinAPI.getch.chr
|
74
|
+
end
|
75
|
+
|
76
|
+
def get_char_echo_blocking
|
77
|
+
WinAPI.getche.chr
|
78
|
+
end
|
79
|
+
|
80
|
+
# Check if IO has user input
|
81
|
+
#
|
82
|
+
# @return [Boolean]
|
83
|
+
#
|
84
|
+
# @api private
|
85
|
+
def input_ready?
|
86
|
+
!WinAPI.kbhit.zero?
|
87
|
+
end
|
88
|
+
end # Console
|
89
|
+
end # Reader
|
90
|
+
end # TTY2
|