rubytui 1.2.3 → 1.2.5
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 +4 -4
- data/README.md +23 -17
- data/lib/rubytui/app.rb +8 -6
- data/lib/rubytui/backends/ansi_backend.rb +83 -50
- data/lib/rubytui/buffer.rb +11 -8
- data/lib/rubytui/errors.rb +2 -0
- data/lib/rubytui/event.rb +14 -4
- data/lib/rubytui/gif.rb +290 -0
- data/lib/rubytui/input/parser.rb +224 -14
- data/lib/rubytui/input/reader.rb +156 -65
- data/lib/rubytui/layout/flex.rb +1 -1
- data/lib/rubytui/layout/layout.rb +25 -10
- data/lib/rubytui/text/line.rb +8 -4
- data/lib/rubytui/text/span.rb +3 -2
- data/lib/rubytui/text/text.rb +10 -5
- data/lib/rubytui/unicode.rb +1 -1
- data/lib/rubytui/version.rb +1 -1
- data/lib/rubytui/widgets/async_image.rb +42 -30
- data/lib/rubytui/widgets/block.rb +5 -5
- data/lib/rubytui/widgets/canvas.rb +26 -14
- data/lib/rubytui/widgets/chart.rb +48 -35
- data/lib/rubytui/widgets/gauge.rb +2 -2
- data/lib/rubytui/widgets/image.rb +47 -20
- data/lib/rubytui/widgets/input_field.rb +59 -17
- data/lib/rubytui/widgets/list.rb +28 -19
- data/lib/rubytui/widgets/paragraph.rb +53 -24
- data/lib/rubytui/widgets/scrollbar.rb +8 -5
- data/lib/rubytui/widgets/table.rb +26 -17
- data/lib/rubytui/widgets/tabs.rb +7 -5
- data/lib/rubytui.rb +15 -9
- metadata +4 -3
data/lib/rubytui/gif.rb
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# Pure-Ruby GIF decoder (GIF87a / GIF89a).
|
|
5
|
+
#
|
|
6
|
+
# Decodes every frame and composites it onto the logical screen, following
|
|
7
|
+
# each frame's disposal method, so every returned frame is a complete image.
|
|
8
|
+
# The canvas starts fully transparent (the logical-screen background colour
|
|
9
|
+
# is ignored), "restore to background" clears to transparent, and "restore to
|
|
10
|
+
# previous" restores the canvas as it was before the frame was drawn.
|
|
11
|
+
# Used by {Widgets::Image} to display GIFs through the Kitty graphics
|
|
12
|
+
# protocol, which only accepts RGB, RGBA or PNG data.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# anim = RubyTUI::GIF.decode(File.binread("spinner.gif"))
|
|
16
|
+
# anim.width # => 32
|
|
17
|
+
# anim.frames.size # => 12
|
|
18
|
+
# anim.delays # => [100, 100, ...] (milliseconds)
|
|
19
|
+
# anim.loops # => 0 (loop forever)
|
|
20
|
+
module GIF
|
|
21
|
+
# Decoded GIF.
|
|
22
|
+
#
|
|
23
|
+
# @!attribute [r] width
|
|
24
|
+
# @return [Integer] logical screen width in pixels
|
|
25
|
+
# @!attribute [r] height
|
|
26
|
+
# @return [Integer] logical screen height in pixels
|
|
27
|
+
# @!attribute [r] frames
|
|
28
|
+
# @return [Array<String>] one RGBA string (4 bytes per pixel) per frame
|
|
29
|
+
# @!attribute [r] delays
|
|
30
|
+
# @return [Array<Integer>] display time of each frame in milliseconds
|
|
31
|
+
# @!attribute [r] loops
|
|
32
|
+
# @return [Integer, nil] NETSCAPE2.0 loop count: +0+ loops forever, +n+
|
|
33
|
+
# repeats the animation +n+ more times, +nil+ (no loop extension) plays once
|
|
34
|
+
Animation = Struct.new(:width, :height, :frames, :delays, :loops)
|
|
35
|
+
|
|
36
|
+
# Largest accepted logical screen width or height, in pixels.
|
|
37
|
+
MAX_DIMENSION = 4096
|
|
38
|
+
|
|
39
|
+
# Largest accepted total decoded size (all frames, RGBA), in bytes.
|
|
40
|
+
MAX_DECODED_BYTES = 256 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
# Delay used for frames that declare less than 20 ms (the common browser convention).
|
|
43
|
+
MIN_DELAY_FALLBACK_MS = 100
|
|
44
|
+
|
|
45
|
+
# Rows of an interlaced image, in the order they are stored: every 8th row
|
|
46
|
+
# from 0, every 8th from 4, every 4th from 2, every 2nd from 1.
|
|
47
|
+
INTERLACE_PASSES = [[0, 8], [4, 8], [2, 4], [1, 2]].freeze
|
|
48
|
+
private_constant :INTERLACE_PASSES
|
|
49
|
+
|
|
50
|
+
# Fully transparent RGBA pixel.
|
|
51
|
+
TRANSPARENT = "\x00\x00\x00\x00".b.freeze
|
|
52
|
+
private_constant :TRANSPARENT
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# Decode GIF data.
|
|
56
|
+
#
|
|
57
|
+
# @param data [String] raw GIF bytes
|
|
58
|
+
# @return [Animation]
|
|
59
|
+
# @raise [ArgumentError] if the data is not a GIF, is truncated or corrupt,
|
|
60
|
+
# or exceeds {MAX_DIMENSION} / {MAX_DECODED_BYTES}
|
|
61
|
+
def decode(data)
|
|
62
|
+
Decoder.new(data.b).decode
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Stateful single-use decoder behind {GIF.decode}.
|
|
67
|
+
# @api private
|
|
68
|
+
class Decoder
|
|
69
|
+
# @param data [String] raw GIF bytes (binary)
|
|
70
|
+
def initialize(data)
|
|
71
|
+
@data = data
|
|
72
|
+
@pos = 0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Animation]
|
|
76
|
+
# @raise [ArgumentError] on invalid or oversized data
|
|
77
|
+
def decode
|
|
78
|
+
signature = read(6)
|
|
79
|
+
raise ArgumentError, "Invalid GIF data: bad signature" unless %w[GIF87a GIF89a].include?(signature)
|
|
80
|
+
|
|
81
|
+
@width, @height, packed, _bg, _aspect = read(7).unpack("vvCCC")
|
|
82
|
+
if @width.zero? || @height.zero? || @width > MAX_DIMENSION || @height > MAX_DIMENSION
|
|
83
|
+
raise ArgumentError, "Invalid GIF data: unsupported size #{@width}x#{@height} " \
|
|
84
|
+
"(1..#{MAX_DIMENSION} pixels per side)"
|
|
85
|
+
end
|
|
86
|
+
@global_palette = packed & 0x80 != 0 ? read_palette(packed & 0x07) : nil
|
|
87
|
+
|
|
88
|
+
@canvas = TRANSPARENT * (@width * @height)
|
|
89
|
+
@frames = []
|
|
90
|
+
@delays = []
|
|
91
|
+
@loops = nil
|
|
92
|
+
@gce = nil
|
|
93
|
+
|
|
94
|
+
loop do
|
|
95
|
+
case read_byte
|
|
96
|
+
when 0x21 then read_extension
|
|
97
|
+
when 0x2C then read_image
|
|
98
|
+
when 0x3B then break
|
|
99
|
+
else raise ArgumentError, "Invalid GIF data: unexpected block at byte #{@pos - 1}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "Invalid GIF data: no image frames" if @frames.empty?
|
|
104
|
+
|
|
105
|
+
Animation.new(@width, @height, @frames, @delays, @loops)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def read(n)
|
|
111
|
+
bytes = @data.byteslice(@pos, n)
|
|
112
|
+
raise ArgumentError, "Invalid GIF data: truncated" if bytes.nil? || bytes.bytesize < n
|
|
113
|
+
|
|
114
|
+
@pos += n
|
|
115
|
+
bytes
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_byte
|
|
119
|
+
read(1).getbyte(0)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Concatenated data sub-blocks up to the block terminator.
|
|
123
|
+
def read_sub_blocks
|
|
124
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
125
|
+
while (size = read_byte) != 0
|
|
126
|
+
out << read(size)
|
|
127
|
+
end
|
|
128
|
+
out
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Palette of 2**(bits + 1) entries, each a 4-byte opaque RGBA string.
|
|
132
|
+
def read_palette(bits)
|
|
133
|
+
read(3 * (2 << bits)).unpack("C*").each_slice(3).map { |r, g, b| [r, g, b, 255].pack("C4") }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def read_extension
|
|
137
|
+
label = read_byte
|
|
138
|
+
body = read_sub_blocks
|
|
139
|
+
case label
|
|
140
|
+
when 0xF9 # Graphic Control Extension
|
|
141
|
+
packed, delay, transparent = body.unpack("CvC")
|
|
142
|
+
@gce = {
|
|
143
|
+
disposal: (packed >> 2) & 0x07,
|
|
144
|
+
delay: delay,
|
|
145
|
+
transparent: packed & 0x01 != 0 ? transparent : nil
|
|
146
|
+
}
|
|
147
|
+
when 0xFF # Application Extension: NETSCAPE2.0 loop count
|
|
148
|
+
if body.start_with?("NETSCAPE2.0", "ANIMEXTS1.0") && body.getbyte(11) == 1
|
|
149
|
+
@loops = body.byteslice(12, 2).unpack1("v")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def read_image
|
|
155
|
+
left, top, w, h, packed = read(9).unpack("vvvvC")
|
|
156
|
+
palette = packed & 0x80 != 0 ? read_palette(packed & 0x07) : @global_palette
|
|
157
|
+
raise ArgumentError, "Invalid GIF data: frame has no colour table" unless palette
|
|
158
|
+
|
|
159
|
+
interlaced = packed & 0x40 != 0
|
|
160
|
+
min_code_size = read_byte
|
|
161
|
+
raise ArgumentError, "Invalid GIF data: bad LZW code size #{min_code_size}" unless (1..8).cover?(min_code_size)
|
|
162
|
+
|
|
163
|
+
indices = lzw_decode(read_sub_blocks, min_code_size, w * h)
|
|
164
|
+
indices = deinterlace(indices, w, h) if interlaced
|
|
165
|
+
|
|
166
|
+
if (@frames.size + 1) * @canvas.bytesize > MAX_DECODED_BYTES
|
|
167
|
+
raise ArgumentError, "GIF too large: decoded frames exceed #{MAX_DECODED_BYTES / (1024 * 1024)} MB"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
gce = @gce || {}
|
|
171
|
+
@gce = nil
|
|
172
|
+
disposal = gce[:disposal] || 0
|
|
173
|
+
saved = @canvas.dup if disposal == 3
|
|
174
|
+
|
|
175
|
+
draw(indices, palette, gce[:transparent], left, top, w, h)
|
|
176
|
+
@frames << @canvas.dup
|
|
177
|
+
delay_ms = (gce[:delay] || 0) * 10
|
|
178
|
+
@delays << (delay_ms < 20 ? MIN_DELAY_FALLBACK_MS : delay_ms)
|
|
179
|
+
|
|
180
|
+
case disposal
|
|
181
|
+
when 2 then clear_rect(left, top, w, h)
|
|
182
|
+
when 3 then @canvas = saved
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# LZW decompression (variable code size, max 12 bits) into palette indices.
|
|
187
|
+
def lzw_decode(data, min_code_size, pixel_count)
|
|
188
|
+
clear = 1 << min_code_size
|
|
189
|
+
eoi = clear + 1
|
|
190
|
+
base = Array.new(clear) { |i| i.chr(Encoding::BINARY) } + [nil, nil]
|
|
191
|
+
dict = base.dup
|
|
192
|
+
code_size = min_code_size + 1
|
|
193
|
+
out = String.new(capacity: pixel_count, encoding: Encoding::BINARY)
|
|
194
|
+
prev = nil
|
|
195
|
+
bits = 0
|
|
196
|
+
nbits = 0
|
|
197
|
+
|
|
198
|
+
data.each_byte do |byte|
|
|
199
|
+
bits |= byte << nbits
|
|
200
|
+
nbits += 8
|
|
201
|
+
while nbits >= code_size
|
|
202
|
+
code = bits & ((1 << code_size) - 1)
|
|
203
|
+
bits >>= code_size
|
|
204
|
+
nbits -= code_size
|
|
205
|
+
|
|
206
|
+
if code == clear
|
|
207
|
+
dict = base.dup
|
|
208
|
+
code_size = min_code_size + 1
|
|
209
|
+
prev = nil
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
return out if code == eoi
|
|
213
|
+
|
|
214
|
+
if prev.nil?
|
|
215
|
+
entry = dict[code]
|
|
216
|
+
raise ArgumentError, "Invalid GIF data: bad LZW code" unless entry
|
|
217
|
+
elsif code < dict.size
|
|
218
|
+
entry = dict[code]
|
|
219
|
+
dict << prev + entry[0] if dict.size < 4096
|
|
220
|
+
elsif code == dict.size
|
|
221
|
+
entry = prev + prev[0]
|
|
222
|
+
dict << entry if dict.size < 4096
|
|
223
|
+
else
|
|
224
|
+
raise ArgumentError, "Invalid GIF data: bad LZW code"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
out << entry
|
|
228
|
+
return out if out.bytesize >= pixel_count
|
|
229
|
+
|
|
230
|
+
prev = entry
|
|
231
|
+
code_size += 1 if dict.size == (1 << code_size) && code_size < 12
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
out
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Reorder interlaced rows into top-to-bottom order.
|
|
238
|
+
def deinterlace(indices, w, h)
|
|
239
|
+
rows = Array.new(h)
|
|
240
|
+
n = 0
|
|
241
|
+
INTERLACE_PASSES.each do |start, step|
|
|
242
|
+
start.step(h - 1, step) do |y|
|
|
243
|
+
rows[y] = indices.byteslice(n * w, w)
|
|
244
|
+
n += 1
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
rows.join
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Composite one frame's pixels onto the canvas, clipped to the screen.
|
|
251
|
+
def draw(indices, palette, transparent, left, top, w, h)
|
|
252
|
+
x_end = [left + w, @width].min
|
|
253
|
+
return if left >= x_end
|
|
254
|
+
|
|
255
|
+
visible = x_end - left
|
|
256
|
+
h.times do |row|
|
|
257
|
+
y = top + row
|
|
258
|
+
break if y >= @height
|
|
259
|
+
|
|
260
|
+
src = indices.byteslice(row * w, visible) || ""
|
|
261
|
+
offset = (y * @width + left) * 4
|
|
262
|
+
if transparent.nil? || !src.include?(transparent.chr(Encoding::BINARY))
|
|
263
|
+
pixels = src.unpack("C*").map! { |i| palette[i] || TRANSPARENT }.join
|
|
264
|
+
@canvas[offset, pixels.bytesize] = pixels
|
|
265
|
+
else
|
|
266
|
+
src.each_byte.with_index do |i, x|
|
|
267
|
+
next if i == transparent
|
|
268
|
+
|
|
269
|
+
@canvas[offset + x * 4, 4] = palette[i] || TRANSPARENT
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Disposal method 2: restore the frame's area to transparent.
|
|
276
|
+
def clear_rect(left, top, w, h)
|
|
277
|
+
x_end = [left + w, @width].min
|
|
278
|
+
return if left >= x_end
|
|
279
|
+
|
|
280
|
+
blank = TRANSPARENT * (x_end - left)
|
|
281
|
+
h.times do |row|
|
|
282
|
+
y = top + row
|
|
283
|
+
break if y >= @height
|
|
284
|
+
|
|
285
|
+
@canvas[(y * @width + left) * 4, blank.bytesize] = blank
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
data/lib/rubytui/input/parser.rb
CHANGED
|
@@ -5,36 +5,224 @@ module RubyTUI
|
|
|
5
5
|
# Decodes raw terminal input into {KeyEvent} and {MouseEvent} objects.
|
|
6
6
|
#
|
|
7
7
|
# Recognised input: printable ASCII, Enter, Tab, Backspace, Ctrl+letter,
|
|
8
|
-
#
|
|
9
|
-
# Alt+character (ESC followed by one character),
|
|
8
|
+
# single UTF-8 characters (as raw bytes, as read by {Reader}, or as a UTF-8
|
|
9
|
+
# String), ESC, Alt+character (ESC followed by one character),
|
|
10
10
|
# CSI and SS3 key sequences (arrows, Home/End, Insert/Delete, Page Up/Down,
|
|
11
11
|
# Back-tab, F1-F12, xterm modified arrows such as <tt>ESC [ 1 ; 5 A</tt>),
|
|
12
|
+
# the same key sequences preceded by ESC (Alt+key as sent by rxvt-style
|
|
13
|
+
# terminals, e.g. <tt>ESC ESC [ A</tt> is Alt+Up),
|
|
12
14
|
# and SGR and legacy mouse reports.
|
|
13
15
|
#
|
|
16
|
+
# String sequences that terminals send as replies are recognised and
|
|
17
|
+
# produce no event: APC (<tt>ESC _</tt>, e.g. Kitty graphics responses),
|
|
18
|
+
# OSC (<tt>ESC ]</tt>), DCS (<tt>ESC P</tt>), SOS (<tt>ESC X</tt>) and PM
|
|
19
|
+
# (<tt>ESC ^</tt>), each ended by ST (ESC and a backslash) or BEL. Because these
|
|
20
|
+
# introducers start a string, Alt+P, Alt+X, Alt+], Alt+^ and Alt+_ are
|
|
21
|
+
# reported only when the input ends with the string they start still
|
|
22
|
+
# unfinished (see {#parse_prefix}).
|
|
23
|
+
#
|
|
24
|
+
# The +char+ of every {KeyEvent} it returns is a UTF-8 String.
|
|
25
|
+
#
|
|
14
26
|
# @example
|
|
15
27
|
# Input::Parser.new.parse("\e[A") # => KeyEvent(up)
|
|
16
28
|
class Parser
|
|
29
|
+
# Longest CSI sequence, in bytes from ESC to the final byte. Recognised
|
|
30
|
+
# keys and SGR mouse reports are far shorter; the limit only bounds the
|
|
31
|
+
# bytes scanned for a final byte.
|
|
32
|
+
MAX_CSI_BYTES = 256
|
|
33
|
+
private_constant :MAX_CSI_BYTES
|
|
34
|
+
|
|
35
|
+
# Second byte of ESC P (DCS), ESC X (SOS), ESC ] (OSC), ESC ^ (PM) and
|
|
36
|
+
# ESC _ (APC), which start string sequences
|
|
37
|
+
STRING_INTRODUCERS = [0x50, 0x58, 0x5D, 0x5E, 0x5F].freeze
|
|
38
|
+
private_constant :STRING_INTRODUCERS
|
|
39
|
+
|
|
17
40
|
# Parse raw bytes into an Event.
|
|
18
41
|
#
|
|
42
|
+
# Input in any encoding is decoded byte-wise; a character is returned as
|
|
43
|
+
# UTF-8. Bytes that are not valid UTF-8 yield nil instead of raising.
|
|
44
|
+
#
|
|
19
45
|
# @param bytes [String, nil] raw bytes of one key press or mouse report
|
|
20
46
|
# @return [KeyEvent, MouseEvent, nil] the decoded event, or nil if +bytes+
|
|
21
|
-
# is nil, empty or not a recognised sequence
|
|
47
|
+
# is nil, empty, invalid UTF-8 or not a recognised sequence
|
|
48
|
+
# @see #parse_prefix
|
|
22
49
|
def parse(bytes)
|
|
23
50
|
return nil if bytes.nil? || bytes.empty?
|
|
24
51
|
|
|
52
|
+
bytes = bytes.b
|
|
25
53
|
# ESC sequence
|
|
26
54
|
if bytes.start_with?("\e")
|
|
27
55
|
parse_escape(bytes)
|
|
28
|
-
elsif bytes.
|
|
56
|
+
elsif bytes.bytesize == 1
|
|
29
57
|
parse_single(bytes)
|
|
30
58
|
else
|
|
31
59
|
# Multi-byte UTF-8 character
|
|
32
|
-
|
|
60
|
+
char = utf8_char(bytes)
|
|
61
|
+
char && KeyEvent.new(code: Key::CHAR, char: char)
|
|
33
62
|
end
|
|
34
63
|
end
|
|
35
64
|
|
|
65
|
+
# Parse the first event at the front of a buffer that may hold several.
|
|
66
|
+
#
|
|
67
|
+
# Finds where the first key press, escape sequence or mouse report in
|
|
68
|
+
# +bytes+ ends and decodes it with {#parse}. Unrecognised sequences are
|
|
69
|
+
# still measured, so the caller can drop them and carry on.
|
|
70
|
+
#
|
|
71
|
+
# A CSI sequence (<tt>ESC [</tt>) is at most 256 bytes long. If no
|
|
72
|
+
# final byte comes within its first 256 bytes, only <tt>ESC [</tt> is
|
|
73
|
+
# taken, as Alt+[, and the bytes after it are left to be decoded as
|
|
74
|
+
# input of their own; this does not depend on +partial+.
|
|
75
|
+
#
|
|
76
|
+
# A terminal string sequence (APC, OSC, DCS, SOS, PM) runs up to and
|
|
77
|
+
# including its ST or BEL terminator and gives no event. An ESC inside
|
|
78
|
+
# it that does not start ST ends it just before that ESC, also with no
|
|
79
|
+
# event. There is no length limit ({Reader} applies one).
|
|
80
|
+
#
|
|
81
|
+
# @param bytes [String] buffered raw input
|
|
82
|
+
# @param partial [Boolean] true if more input may follow +bytes+: an
|
|
83
|
+
# incomplete sequence at the front (such as a lone ESC, an unfinished
|
|
84
|
+
# string sequence, or ESC ESC that may start Alt+key) is then left
|
|
85
|
+
# unconsumed. When false, it is decoded as it stands: ESC ESC is a
|
|
86
|
+
# plain ESC key; an unfinished string sequence (no terminator, and no
|
|
87
|
+
# ESC followed by another byte) is taken to be typed, so only its
|
|
88
|
+
# introducer is taken, as Alt+character (2 bytes, e.g. <tt>ESC P</tt>
|
|
89
|
+
# is Alt+P), and the bytes after it are left to be decoded as input
|
|
90
|
+
# of their own; any other unfinished sequence is taken whole (a lone
|
|
91
|
+
# ESC is the ESC key, <tt>ESC [</tt> and <tt>ESC O</tt> are Alt+[ and
|
|
92
|
+
# Alt+O, and a cut-off UTF-8 character, mouse report or longer CSI
|
|
93
|
+
# sequence gives no event) (default: false)
|
|
94
|
+
# @return [Array(Event, Integer)] a two-element array: the event (nil if
|
|
95
|
+
# the sequence was not recognised or is a string sequence) and the
|
|
96
|
+
# number of bytes it took up. The count is 0 only when +bytes+ is
|
|
97
|
+
# empty or, with +partial+, holds just the start of a sequence.
|
|
98
|
+
#
|
|
99
|
+
# @example
|
|
100
|
+
# Input::Parser.new.parse_prefix("\e[A\e[B") # => [KeyEvent(up), 3]
|
|
101
|
+
# Input::Parser.new.parse_prefix("\e_Gi=1;OK\e\\q") # => [nil, 11]
|
|
102
|
+
# Input::Parser.new.parse_prefix("\ePab") # => [KeyEvent(Alt+P), 2]
|
|
103
|
+
def parse_prefix(bytes, partial: false)
|
|
104
|
+
bytes = bytes.b unless bytes.encoding == Encoding::BINARY
|
|
105
|
+
length = sequence_length(bytes)
|
|
106
|
+
length ||= partial ? 0 : incomplete_length(bytes)
|
|
107
|
+
return [nil, 0] if length.zero?
|
|
108
|
+
|
|
109
|
+
[parse(bytes.byteslice(0, length)), length]
|
|
110
|
+
end
|
|
111
|
+
|
|
36
112
|
private
|
|
37
113
|
|
|
114
|
+
# Byte length of the first sequence in +bytes+ (binary), 0 if empty,
|
|
115
|
+
# or nil if +bytes+ ends before the sequence is complete.
|
|
116
|
+
def sequence_length(bytes)
|
|
117
|
+
return 0 if bytes.empty?
|
|
118
|
+
|
|
119
|
+
first = bytes.getbyte(0)
|
|
120
|
+
if first == 0x1B
|
|
121
|
+
escape_length(bytes)
|
|
122
|
+
elsif first < 0x80
|
|
123
|
+
1
|
|
124
|
+
else
|
|
125
|
+
utf8_length(bytes, 0)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Length to take for a sequence cut short by the end of input: all of
|
|
130
|
+
# it, except that ESC ESC starts with a plain ESC key and an unfinished
|
|
131
|
+
# string sequence with its introducer (Alt+character).
|
|
132
|
+
def incomplete_length(bytes)
|
|
133
|
+
return 1 if bytes.start_with?("\e\e")
|
|
134
|
+
return 2 if bytes.getbyte(0) == 0x1B && STRING_INTRODUCERS.include?(bytes.getbyte(1))
|
|
135
|
+
|
|
136
|
+
bytes.bytesize
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def escape_length(bytes)
|
|
140
|
+
second = bytes.getbyte(1)
|
|
141
|
+
case second
|
|
142
|
+
when nil then nil # lone ESC so far
|
|
143
|
+
when 0x1B then alt_sequence_length(bytes)
|
|
144
|
+
when *STRING_INTRODUCERS
|
|
145
|
+
string_length(bytes)
|
|
146
|
+
when 0x5B # CSI: ESC [
|
|
147
|
+
third = bytes.getbyte(2)
|
|
148
|
+
if third.nil?
|
|
149
|
+
nil
|
|
150
|
+
elsif third == 0x4D # legacy mouse: ESC [ M Cb Cx Cy
|
|
151
|
+
bytes.bytesize >= 6 ? 6 : nil
|
|
152
|
+
else
|
|
153
|
+
csi_length(bytes)
|
|
154
|
+
end
|
|
155
|
+
when 0x4F # SS3: ESC O X
|
|
156
|
+
bytes.bytesize >= 3 ? 3 : nil
|
|
157
|
+
else # Alt+character
|
|
158
|
+
return 2 if second < 0x80
|
|
159
|
+
|
|
160
|
+
char_length = utf8_length(bytes, 1)
|
|
161
|
+
char_length && char_length + 1
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# CSI: parameter/intermediate bytes 0x20-0x3F, then a final byte 0x40-0x7E.
|
|
166
|
+
# Any other byte ends a malformed sequence just before it. With no
|
|
167
|
+
# final byte within MAX_CSI_BYTES, ESC [ alone (Alt+[).
|
|
168
|
+
def csi_length(bytes)
|
|
169
|
+
(2...[bytes.bytesize, MAX_CSI_BYTES].min).each do |i|
|
|
170
|
+
b = bytes.getbyte(i)
|
|
171
|
+
return i + 1 if b.between?(0x40, 0x7E)
|
|
172
|
+
return i unless b.between?(0x20, 0x3F)
|
|
173
|
+
end
|
|
174
|
+
bytes.bytesize >= MAX_CSI_BYTES ? 2 : nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ESC ESC: both ESCs and the CSI or SS3 sequence after the second, if
|
|
178
|
+
# together they are a key (Alt+key); otherwise 1, the first ESC as a
|
|
179
|
+
# plain key.
|
|
180
|
+
def alt_sequence_length(bytes)
|
|
181
|
+
rest = bytes.byteslice(1..)
|
|
182
|
+
return 1 unless [nil, 0x5B, 0x4F].include?(rest.getbyte(1))
|
|
183
|
+
|
|
184
|
+
length = escape_length(rest)
|
|
185
|
+
return nil if length.nil?
|
|
186
|
+
|
|
187
|
+
parse(bytes.byteslice(0, length + 1)).is_a?(KeyEvent) ? length + 1 : 1
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# String sequence: ends after BEL or ST (ESC \), or just before any other
|
|
191
|
+
# ESC; nil while no terminator or other ESC has come.
|
|
192
|
+
def string_length(bytes)
|
|
193
|
+
stop = bytes.index(/[\a\e]/, 2)
|
|
194
|
+
return nil if stop.nil?
|
|
195
|
+
return stop + 1 if bytes.getbyte(stop) == 0x07
|
|
196
|
+
|
|
197
|
+
following = bytes.getbyte(stop + 1)
|
|
198
|
+
return nil if following.nil? # ESC at the end: may be the start of ST
|
|
199
|
+
|
|
200
|
+
following == 0x5C ? stop + 2 : stop
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Length of the UTF-8 character starting at +start+; an invalid sequence
|
|
204
|
+
# ends just before the first byte that does not continue it.
|
|
205
|
+
def utf8_length(bytes, start)
|
|
206
|
+
need = case bytes.getbyte(start)
|
|
207
|
+
when 0xC2..0xDF then 2
|
|
208
|
+
when 0xE0..0xEF then 3
|
|
209
|
+
when 0xF0..0xF4 then 4
|
|
210
|
+
else 1
|
|
211
|
+
end
|
|
212
|
+
(1...need).each do |k|
|
|
213
|
+
b = bytes.getbyte(start + k)
|
|
214
|
+
return nil if b.nil?
|
|
215
|
+
return k unless b.between?(0x80, 0xBF)
|
|
216
|
+
end
|
|
217
|
+
need
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# +raw+ as a UTF-8 String if it is exactly one valid character, else nil.
|
|
221
|
+
def utf8_char(raw)
|
|
222
|
+
char = String.new(raw, encoding: Encoding::UTF_8)
|
|
223
|
+
char if char.valid_encoding? && char.length == 1
|
|
224
|
+
end
|
|
225
|
+
|
|
38
226
|
def parse_single(byte)
|
|
39
227
|
ord = byte.ord
|
|
40
228
|
|
|
@@ -49,11 +237,11 @@ module RubyTUI
|
|
|
49
237
|
KeyEvent.new(code: Key::BACKSPACE)
|
|
50
238
|
when 1..26
|
|
51
239
|
# Ctrl+A through Ctrl+Z
|
|
52
|
-
char = (ord + 96).chr # Convert to lowercase letter
|
|
240
|
+
char = (ord + 96).chr(Encoding::UTF_8) # Convert to lowercase letter
|
|
53
241
|
KeyEvent.new(code: Key::CHAR, char: char, modifiers: Key::CTRL)
|
|
54
242
|
when 32..126
|
|
55
243
|
# Printable ASCII
|
|
56
|
-
KeyEvent.new(code: Key::CHAR, char: byte)
|
|
244
|
+
KeyEvent.new(code: Key::CHAR, char: utf8_char(byte))
|
|
57
245
|
else
|
|
58
246
|
nil
|
|
59
247
|
end
|
|
@@ -79,9 +267,16 @@ module RubyTUI
|
|
|
79
267
|
# SS3 sequences: ESC O ...
|
|
80
268
|
when /\A\eO([A-Za-z])\z/
|
|
81
269
|
parse_ss3($1)
|
|
82
|
-
# Alt+
|
|
83
|
-
when /\A\e(
|
|
84
|
-
|
|
270
|
+
# Alt+key (rxvt-style): ESC + CSI or SS3 key sequence
|
|
271
|
+
when /\A\e(\e[\[O].+)\z/m
|
|
272
|
+
key = parse_escape($1)
|
|
273
|
+
if key.is_a?(KeyEvent)
|
|
274
|
+
KeyEvent.new(code: key.code, char: key.char, modifiers: key.modifiers | Key::ALT)
|
|
275
|
+
end
|
|
276
|
+
# Alt+character: ESC + one (possibly multi-byte) character
|
|
277
|
+
when /\A\e(.+)\z/
|
|
278
|
+
char = utf8_char($1)
|
|
279
|
+
char && KeyEvent.new(code: Key::CHAR, char: char, modifiers: Key::ALT)
|
|
85
280
|
else
|
|
86
281
|
nil
|
|
87
282
|
end
|
|
@@ -154,7 +349,7 @@ module RubyTUI
|
|
|
154
349
|
end
|
|
155
350
|
|
|
156
351
|
# SGR extended mouse mode: ESC [ < Cb ; Cx ; Cy M (press) or m (release)
|
|
157
|
-
# Cb encodes button + modifiers +
|
|
352
|
+
# Cb encodes button + modifiers + motion flag
|
|
158
353
|
def parse_sgr_mouse(cb, cx, cy, terminator)
|
|
159
354
|
# Coordinates are 1-based in SGR mode
|
|
160
355
|
x = cx - 1
|
|
@@ -172,8 +367,12 @@ module RubyTUI
|
|
|
172
367
|
is_scroll = (cb & 64) != 0
|
|
173
368
|
|
|
174
369
|
if is_scroll
|
|
175
|
-
button = button_bits
|
|
370
|
+
button = scroll_button(button_bits)
|
|
176
371
|
action = MouseEvent::PRESS
|
|
372
|
+
elsif is_drag && button_bits == 3
|
|
373
|
+
# Motion with no button held
|
|
374
|
+
button = MouseEvent::NONE
|
|
375
|
+
action = MouseEvent::MOVE
|
|
177
376
|
else
|
|
178
377
|
button = case button_bits
|
|
179
378
|
when 0 then MouseEvent::LEFT
|
|
@@ -209,11 +408,12 @@ module RubyTUI
|
|
|
209
408
|
button_bits = cb & 3
|
|
210
409
|
|
|
211
410
|
if is_scroll
|
|
212
|
-
button = button_bits
|
|
411
|
+
button = scroll_button(button_bits)
|
|
213
412
|
action = MouseEvent::PRESS
|
|
214
413
|
elsif button_bits == 3
|
|
414
|
+
# Release, or motion with no button held
|
|
215
415
|
button = MouseEvent::NONE
|
|
216
|
-
action = MouseEvent::RELEASE
|
|
416
|
+
action = is_drag ? MouseEvent::MOVE : MouseEvent::RELEASE
|
|
217
417
|
else
|
|
218
418
|
button = case button_bits
|
|
219
419
|
when 0 then MouseEvent::LEFT
|
|
@@ -226,6 +426,16 @@ module RubyTUI
|
|
|
226
426
|
|
|
227
427
|
MouseEvent.new(button: button, action: action, x: x, y: y, modifiers: mods)
|
|
228
428
|
end
|
|
429
|
+
|
|
430
|
+
# Wheel button for the low two bits of a wheel report (codes 64-67)
|
|
431
|
+
def scroll_button(button_bits)
|
|
432
|
+
case button_bits
|
|
433
|
+
when 0 then MouseEvent::SCROLL_UP
|
|
434
|
+
when 1 then MouseEvent::SCROLL_DOWN
|
|
435
|
+
when 2 then MouseEvent::SCROLL_LEFT
|
|
436
|
+
else MouseEvent::SCROLL_RIGHT
|
|
437
|
+
end
|
|
438
|
+
end
|
|
229
439
|
end
|
|
230
440
|
end
|
|
231
441
|
end
|