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.
@@ -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
@@ -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
- # multi-byte UTF-8 characters given as raw bytes (as read by {Reader}), ESC,
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.length == 1
56
+ elsif bytes.bytesize == 1
29
57
  parse_single(bytes)
30
58
  else
31
59
  # Multi-byte UTF-8 character
32
- KeyEvent.new(code: Key::CHAR, char: bytes)
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+letter: ESC + letter
83
- when /\A\e(.)\z/
84
- KeyEvent.new(code: Key::CHAR, char: $1, modifiers: Key::ALT)
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 + drag flag
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 == 0 ? MouseEvent::SCROLL_UP : MouseEvent::SCROLL_DOWN
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 == 0 ? MouseEvent::SCROLL_UP : MouseEvent::SCROLL_DOWN
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