echoes 0.2.0

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,821 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ class Parser
5
+ def initialize(screen, writer: nil)
6
+ @screen = screen
7
+ @writer = writer
8
+ @state = :ground
9
+ @params = []
10
+ @current_param = +""
11
+ @private_flag = false
12
+ @csi_prefix = nil # tracks <, =, > prefix bytes in CSI
13
+ @csi_intermediate = nil
14
+ @osc_string = +""
15
+ @esc_intermediate = nil
16
+ @dcs_params = []
17
+ @dcs_current_param = +""
18
+ @dcs_data = "".b
19
+ @utf8_buf = "".b
20
+ @utf8_remaining = 0
21
+ end
22
+
23
+ def feed(data)
24
+ data.each_byte do |byte|
25
+ process_byte(byte)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ REPLACEMENT_CHAR = "\u{FFFD}"
32
+ CSI_PARAM_LIMIT = 32
33
+ OSC_BUFFER_LIMIT = 4096
34
+ DCS_BUFFER_LIMIT = 1024 * 1024 # 1 MB (sixel images can be large)
35
+
36
+ def process_byte(byte)
37
+ # UTF-8 continuation bytes
38
+ if @utf8_remaining > 0
39
+ if byte >= 0x80 && byte <= 0xBF
40
+ @utf8_buf << byte.chr(Encoding::BINARY)
41
+ @utf8_remaining -= 1
42
+ if @utf8_remaining == 0
43
+ char = @utf8_buf.force_encoding('UTF-8')
44
+ if char.valid_encoding?
45
+ @screen.put_char(char)
46
+ else
47
+ @screen.put_char(REPLACEMENT_CHAR)
48
+ end
49
+ @utf8_buf = "".b
50
+ end
51
+ else
52
+ # Not a continuation byte — emit replacement for truncated sequence
53
+ @screen.put_char(REPLACEMENT_CHAR)
54
+ @utf8_remaining = 0
55
+ @utf8_buf = "".b
56
+ # Reprocess this byte
57
+ process_byte(byte)
58
+ end
59
+ return
60
+ end
61
+
62
+ case @state
63
+ when :ground
64
+ ground(byte)
65
+ when :escape
66
+ escape(byte)
67
+ when :escape_intermediate
68
+ escape_intermediate(byte)
69
+ when :csi_entry
70
+ csi_entry(byte)
71
+ when :csi_param
72
+ csi_param(byte)
73
+ when :osc_string
74
+ osc_string(byte)
75
+ when :dcs_entry
76
+ dcs_entry(byte)
77
+ when :dcs_param
78
+ dcs_param(byte)
79
+ when :dcs_passthrough
80
+ dcs_passthrough(byte)
81
+ end
82
+ end
83
+
84
+ def ground(byte)
85
+ case byte
86
+ when 0x1B # ESC
87
+ @state = :escape
88
+ when 0x0D # CR
89
+ @screen.carriage_return
90
+ when 0x0A, 0x0B, 0x0C # LF, VT, FF
91
+ @screen.line_feed
92
+ when 0x08 # BS
93
+ @screen.backspace
94
+ when 0x09 # HT
95
+ @screen.tab
96
+ when 0x0E # SO (shift out -> G1)
97
+ @screen.active_charset = 1
98
+ when 0x0F # SI (shift in -> G0)
99
+ @screen.active_charset = 0
100
+ when 0x07 # BEL
101
+ @screen.bell = true
102
+ when 0x00..0x1F
103
+ # ignore other C0 controls
104
+ when 0x20..0x7E # printable ASCII
105
+ @screen.put_char(byte.chr)
106
+ when 0x80..0xBF # unexpected continuation byte
107
+ @screen.put_char(REPLACEMENT_CHAR)
108
+ when 0xC2..0xDF # UTF-8 2-byte
109
+ @utf8_buf = byte.chr(Encoding::BINARY)
110
+ @utf8_remaining = 1
111
+ when 0xE0..0xEF # UTF-8 3-byte
112
+ @utf8_buf = byte.chr(Encoding::BINARY)
113
+ @utf8_remaining = 2
114
+ when 0xF0..0xF4 # UTF-8 4-byte (up to U+10FFFF)
115
+ @utf8_buf = byte.chr(Encoding::BINARY)
116
+ @utf8_remaining = 3
117
+ when 0xC0, 0xC1, 0xF5..0xFF # invalid lead bytes
118
+ @screen.put_char(REPLACEMENT_CHAR)
119
+ end
120
+ end
121
+
122
+ def escape(byte)
123
+ case byte
124
+ when 0x5B # [
125
+ @state = :csi_entry
126
+ @params = []
127
+ @current_param = +""
128
+ @private_flag = false
129
+ @csi_prefix = nil
130
+ @csi_intermediate = nil
131
+ when 0x50 # P (DCS)
132
+ @state = :dcs_entry
133
+ @dcs_params = []
134
+ @dcs_current_param = +""
135
+ @dcs_data = "".b
136
+ @dcs_intermediate = nil
137
+ when 0x5D # ]
138
+ @state = :osc_string
139
+ @osc_string = "".b
140
+ when 0x37 # 7
141
+ @screen.save_cursor
142
+ @state = :ground
143
+ when 0x38 # 8
144
+ @screen.restore_cursor
145
+ @state = :ground
146
+ when 0x63 # c
147
+ @screen.reset
148
+ @state = :ground
149
+ when 0x44 # D
150
+ @screen.line_feed
151
+ @state = :ground
152
+ when 0x45 # E (NEL - Next Line)
153
+ @screen.carriage_return
154
+ @screen.line_feed
155
+ @state = :ground
156
+ when 0x48 # H (HTS - Horizontal Tab Set)
157
+ @screen.set_tab_stop
158
+ @state = :ground
159
+ when 0x4D # M
160
+ @screen.reverse_index
161
+ @state = :ground
162
+ when 0x4E # N (SS2 — single shift G2)
163
+ @screen.single_shift = 2
164
+ @state = :ground
165
+ when 0x4F # O (SS3 — single shift G3)
166
+ @screen.single_shift = 3
167
+ @state = :ground
168
+ when 0x3D # = (application keypad mode)
169
+ @screen.application_keypad = true
170
+ @state = :ground
171
+ when 0x3E # > (normal keypad mode)
172
+ @screen.application_keypad = false
173
+ @state = :ground
174
+ when 0x20..0x2F # intermediate bytes
175
+ @esc_intermediate = byte
176
+ @state = :escape_intermediate
177
+ else
178
+ @state = :ground
179
+ end
180
+ end
181
+
182
+ def escape_intermediate(byte)
183
+ case byte
184
+ when 0x20..0x2F
185
+ @esc_intermediate = byte
186
+ when 0x30..0x7E
187
+ dispatch_escape_intermediate(byte)
188
+ @state = :ground
189
+ else
190
+ @state = :ground
191
+ end
192
+ end
193
+
194
+ def dispatch_escape_intermediate(final)
195
+ case @esc_intermediate
196
+ when 0x23 # #
197
+ @screen.decaln if final == 0x38 # ESC # 8
198
+ when 0x28 # ( => G0
199
+ charset = final == 0x30 ? :dec_special : :ascii
200
+ @screen.designate_charset(0, charset)
201
+ when 0x29 # ) => G1
202
+ charset = final == 0x30 ? :dec_special : :ascii
203
+ @screen.designate_charset(1, charset)
204
+ when 0x2A # * => G2
205
+ charset = final == 0x30 ? :dec_special : :ascii
206
+ @screen.designate_charset(2, charset)
207
+ when 0x2B # + => G3
208
+ charset = final == 0x30 ? :dec_special : :ascii
209
+ @screen.designate_charset(3, charset)
210
+ end
211
+ end
212
+
213
+ def csi_entry(byte)
214
+ case byte
215
+ when 0x3F # ?
216
+ @private_flag = true
217
+ @state = :csi_param
218
+ when 0x3C..0x3E # <, =, > (private parameter prefixes)
219
+ @csi_prefix = byte
220
+ @state = :csi_param
221
+ when 0x30..0x39 # 0-9
222
+ @current_param << byte.chr
223
+ @state = :csi_param
224
+ when 0x3A # : (sub-parameter separator)
225
+ @current_param << ':'
226
+ @state = :csi_param
227
+ when 0x3B # ;
228
+ @params << @current_param if @params.size < CSI_PARAM_LIMIT
229
+ @current_param = +""
230
+ @state = :csi_param
231
+ when 0x20..0x2F # intermediate bytes
232
+ @csi_intermediate = byte.chr
233
+ @state = :csi_param
234
+ when 0x40..0x7E # final byte
235
+ dispatch_csi(byte.chr)
236
+ @state = :ground
237
+ when 0x18, 0x1A # CAN, SUB — abort sequence
238
+ @state = :ground
239
+ else
240
+ @state = :csi_param
241
+ end
242
+ end
243
+
244
+ def csi_param(byte)
245
+ case byte
246
+ when 0x30..0x39 # 0-9
247
+ @current_param << byte.chr
248
+ when 0x3A # : (sub-parameter separator)
249
+ @current_param << ':'
250
+ when 0x3B # ;
251
+ @params << @current_param if @params.size < CSI_PARAM_LIMIT
252
+ @current_param = +""
253
+ when 0x20..0x2F # intermediate bytes
254
+ @csi_intermediate = byte.chr
255
+ when 0x40..0x7E # final byte
256
+ dispatch_csi(byte.chr)
257
+ @state = :ground
258
+ when 0x1B # ESC interrupts
259
+ @state = :escape
260
+ when 0x18, 0x1A # CAN, SUB — abort sequence
261
+ @state = :ground
262
+ when 0x0D # CR
263
+ @screen.carriage_return
264
+ when 0x0A, 0x0B, 0x0C # LF, VT, FF
265
+ @screen.line_feed
266
+ when 0x08 # BS
267
+ @screen.backspace
268
+ when 0x09 # HT
269
+ @screen.tab
270
+ when 0x07 # BEL
271
+ @screen.bell = true
272
+ end
273
+ end
274
+
275
+ def osc_string(byte)
276
+ case byte
277
+ when 0x07 # BEL terminates OSC
278
+ dispatch_osc
279
+ @state = :ground
280
+ when 0x1B # ESC — dispatch OSC, enter escape state for ST (\)
281
+ dispatch_osc
282
+ @state = :escape
283
+ else
284
+ if @osc_string.bytesize < OSC_BUFFER_LIMIT
285
+ @osc_string << byte
286
+ else
287
+ @state = :ground
288
+ end
289
+ end
290
+ end
291
+
292
+ def dcs_entry(byte)
293
+ case byte
294
+ when 0x30..0x39
295
+ @dcs_current_param << byte.chr
296
+ @state = :dcs_param
297
+ when 0x3B
298
+ @dcs_params << @dcs_current_param
299
+ @dcs_current_param = +""
300
+ @state = :dcs_param
301
+ when 0x20..0x2F # intermediate bytes (+, etc.)
302
+ @dcs_intermediate = byte
303
+ @state = :dcs_param
304
+ when 0x40..0x7E # final byte
305
+ @dcs_params << @dcs_current_param unless @dcs_current_param.empty?
306
+ if byte == 0x71 # 'q'
307
+ @state = :dcs_passthrough
308
+ else
309
+ @state = :ground
310
+ end
311
+ when 0x1B
312
+ @state = :escape
313
+ end
314
+ end
315
+
316
+ def dcs_param(byte)
317
+ case byte
318
+ when 0x30..0x39
319
+ @dcs_current_param << byte.chr
320
+ when 0x3B
321
+ @dcs_params << @dcs_current_param
322
+ @dcs_current_param = +""
323
+ when 0x20..0x2F # intermediate bytes
324
+ @dcs_intermediate = byte
325
+ when 0x40..0x7E # final byte
326
+ @dcs_params << @dcs_current_param unless @dcs_current_param.empty?
327
+ if byte == 0x71 # 'q'
328
+ @state = :dcs_passthrough
329
+ else
330
+ @state = :ground
331
+ end
332
+ when 0x1B
333
+ @state = :escape
334
+ end
335
+ end
336
+
337
+ def dcs_passthrough(byte)
338
+ if byte == 0x1B
339
+ dispatch_dcs
340
+ @state = :escape
341
+ elsif @dcs_data.bytesize < DCS_BUFFER_LIMIT
342
+ @dcs_data << byte
343
+ else
344
+ @state = :ground
345
+ end
346
+ end
347
+
348
+ def dispatch_dcs
349
+ if @dcs_intermediate == 0x2B # '+' => XTGETTCAP
350
+ dispatch_xtgettcap(@dcs_data)
351
+ else
352
+ params = @dcs_params.map { |s| s.empty? ? 0 : s.to_i }
353
+ @screen.put_sixel(@dcs_data, params)
354
+ end
355
+ end
356
+
357
+ TCAP_RESPONSES = {
358
+ 'Co' => '256', # max colors
359
+ 'RGB' => '1', # direct color support
360
+ 'Su' => '1', # styled underlines
361
+ 'Ms' => '\033]52;%p1%s;%p2%s\033\\\\', # clipboard (OSC 52)
362
+ }.freeze
363
+
364
+ def dispatch_xtgettcap(data)
365
+ return unless @writer
366
+
367
+ # Data contains hex-encoded capability names separated by ';'
368
+ names = data.force_encoding('ASCII').split(';')
369
+ names.each do |hex_name|
370
+ name = [hex_name].pack('H*') rescue next
371
+ value = name == 'TN' ? Echoes.config.term : TCAP_RESPONSES[name]
372
+ if value
373
+ hex_value = value.unpack1('H*')
374
+ hex_key = name.unpack1('H*')
375
+ @writer.call("\eP1+r#{hex_key}=#{hex_value}\e\\")
376
+ else
377
+ hex_key = name.unpack1('H*')
378
+ @writer.call("\eP0+r#{hex_key}\e\\")
379
+ end
380
+ end
381
+ end
382
+
383
+ def dispatch_osc
384
+ code, rest = @osc_string.split(';'.b, 2)
385
+ return unless rest
386
+
387
+ code.force_encoding('UTF-8')
388
+ rest.force_encoding('UTF-8')
389
+
390
+ case code
391
+ when '0', '2'
392
+ @screen.title = rest
393
+ return
394
+ when '8'
395
+ _params, uri = rest.split(';', 2)
396
+ @screen.set_hyperlink(uri && !uri.empty? ? uri : nil)
397
+ return
398
+ when '7'
399
+ @screen.current_directory = rest
400
+ return
401
+ when '4'
402
+ dispatch_osc4(rest)
403
+ return
404
+ when '10'
405
+ dispatch_osc_default_color(:fg, 10, rest)
406
+ return
407
+ when '11'
408
+ dispatch_osc_default_color(:bg, 11, rest)
409
+ return
410
+ when '12'
411
+ dispatch_osc_default_color(:cursor, 12, rest)
412
+ return
413
+ when '52'
414
+ dispatch_osc52(rest)
415
+ return
416
+ when '133'
417
+ dispatch_osc133(rest)
418
+ return
419
+ when '7772'
420
+ dispatch_osc7772(rest)
421
+ return
422
+ when '66'
423
+ # fall through to multicell handling below
424
+ else
425
+ return
426
+ end
427
+ meta_str, text = rest.split(';', 2)
428
+ return unless text
429
+
430
+ text.force_encoding('UTF-8')
431
+ # `f=` is an Echoes extension that other terminals ignore.
432
+ # Family names containing `:` aren't representable here because
433
+ # `:` is the meta-field separator — use `,` or omit the colon
434
+ # in the family name (e.g. "Helvetica Neue", not "Foo:Italic").
435
+ params = {scale: 1, width: 0, frac_n: 0, frac_d: 0, valign: 0, halign: 0, family: nil}
436
+ meta_str.split(':').each do |pair|
437
+ k, v = pair.split('=', 2)
438
+ next unless v
439
+ case k
440
+ when 's' then params[:scale] = v.to_i.clamp(1, 7)
441
+ when 'w' then params[:width] = v.to_i.clamp(0, 7)
442
+ when 'n' then params[:frac_n] = v.to_i.clamp(0, 15)
443
+ when 'd' then params[:frac_d] = v.to_i.clamp(0, 15)
444
+ when 'v' then params[:valign] = v.to_i.clamp(0, 2)
445
+ when 'h' then params[:halign] = v.to_i.clamp(0, 2)
446
+ when 'f' then params[:family] = v unless v.empty?
447
+ end
448
+ end
449
+
450
+ @screen.put_multicell(text, **params)
451
+ end
452
+
453
+ def dispatch_osc_default_color(key, osc_code, spec)
454
+ if spec == '?'
455
+ if @writer && @screen.palette_handler
456
+ rgb = @screen.palette_handler.call(:get, key)
457
+ if rgb
458
+ r, g, b = rgb
459
+ @writer.call("\e]#{osc_code};rgb:#{format('%04x', r)}/#{format('%04x', g)}/#{format('%04x', b)}\e\\")
460
+ end
461
+ end
462
+ elsif spec =~ /\Argb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)\z/
463
+ r = scale_color_component($1)
464
+ g = scale_color_component($2)
465
+ b = scale_color_component($3)
466
+ @screen.palette_handler&.call(:set, key, [r, g, b])
467
+ end
468
+ end
469
+
470
+ def dispatch_osc4(rest)
471
+ # OSC 4 can contain multiple index;spec pairs
472
+ parts = rest.split(';')
473
+ parts.each_slice(2) do |index_str, spec|
474
+ break unless spec
475
+ idx = index_str.to_i
476
+ next if idx < 0 || idx > 255
477
+
478
+ if spec == '?'
479
+ # Query: respond with current color
480
+ if @writer && @screen.palette_handler
481
+ rgb = @screen.palette_handler.call(:get, idx)
482
+ if rgb
483
+ r, g, b = rgb
484
+ @writer.call("\e]4;#{idx};rgb:#{format('%04x', r)}/#{format('%04x', g)}/#{format('%04x', b)}\e\\")
485
+ end
486
+ end
487
+ else
488
+ # Set: parse color spec (rgb:RR/GG/BB or rgb:RRRR/GGGG/BBBB)
489
+ if spec =~ /\Argb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)\z/
490
+ r_s, g_s, b_s = $1, $2, $3
491
+ r = scale_color_component(r_s)
492
+ g = scale_color_component(g_s)
493
+ b = scale_color_component(b_s)
494
+ @screen.palette_handler&.call(:set, idx, [r, g, b])
495
+ end
496
+ end
497
+ end
498
+ end
499
+
500
+ def scale_color_component(hex)
501
+ val = hex.to_i(16)
502
+ case hex.length
503
+ when 1 then val * 0x1111
504
+ when 2 then val * 0x0101
505
+ when 3 then val * 0x0010 + (val >> 4)
506
+ when 4 then val
507
+ else val
508
+ end
509
+ end
510
+
511
+ # OSC 133 — semantic prompt boundaries. The wider ecosystem
512
+ # (FinalTerm, iTerm2, kitty, WezTerm, VS Code) reads these to enable
513
+ # click-to-rerun, semantic copy, jump-by-prompt, and pipe-to-AI.
514
+ # Echoes records them on the Screen so future UI features (and
515
+ # tests, today) can navigate by command.
516
+ # Echoes-private OSC namespace. Format:
517
+ # \e]7772;<command>;<args>\a
518
+ # Other terminals ignore the unknown OSC code, so emitters degrade
519
+ # gracefully. Supported commands:
520
+ # bg-color ; #rrggbb (solid pane background)
521
+ # bg-gradient ; type=linear:angle=N:colors=#rrggbb,#rrggbb[,...]
522
+ # bg-fill ; color=#rrggbb:rect=row1,col1,row2,col2
523
+ # bg-clear (revert to default_bg)
524
+ def dispatch_osc7772(rest)
525
+ command, args = rest.split(';', 2)
526
+ case command
527
+ when 'bg-color'
528
+ rgba = parse_hex_color((args || '').strip)
529
+ if rgba
530
+ @screen.background = {type: :flat, colors: [rgba]}
531
+ @screen.mark_all_dirty if @screen.respond_to?(:mark_all_dirty)
532
+ end
533
+ when 'bg-gradient'
534
+ spec = parse_bg_gradient_args(args || '')
535
+ if spec
536
+ @screen.background = spec
537
+ @screen.mark_all_dirty if @screen.respond_to?(:mark_all_dirty)
538
+ end
539
+ when 'bg-fill'
540
+ fill = parse_bg_fill_args(args || '')
541
+ if fill && @screen.respond_to?(:bg_fills) && @screen.bg_fills
542
+ @screen.bg_fills << fill
543
+ @screen.mark_all_dirty if @screen.respond_to?(:mark_all_dirty)
544
+ end
545
+ when 'bg-clear'
546
+ @screen.background = nil
547
+ @screen.bg_fills.clear if @screen.respond_to?(:bg_fills) && @screen.bg_fills
548
+ @screen.mark_all_dirty if @screen.respond_to?(:mark_all_dirty)
549
+ end
550
+ end
551
+
552
+ def parse_bg_gradient_args(args)
553
+ params = {type: :linear, angle: 0.0, colors: []}
554
+ args.split(':').each do |pair|
555
+ k, v = pair.split('=', 2)
556
+ next unless v
557
+ case k
558
+ when 'type'
559
+ params[:type] = v.to_sym
560
+ when 'angle'
561
+ params[:angle] = v.to_f
562
+ when 'colors'
563
+ params[:colors] = v.split(',').map { |hex| parse_hex_color(hex) }.compact
564
+ end
565
+ end
566
+ return nil if params[:colors].size < 2
567
+ params
568
+ end
569
+
570
+ # Parse `color=#rrggbb:rect=r1,c1,r2,c2` into
571
+ # `{rect: [r1,c1,r2,c2], color: [r,g,b,a]}`. Returns nil if
572
+ # either field is missing or malformed; the renderer clamps the
573
+ # rect to the pane bounds, so out-of-range values are tolerated.
574
+ def parse_bg_fill_args(args)
575
+ color = nil
576
+ rect = nil
577
+ args.split(':').each do |pair|
578
+ k, v = pair.split('=', 2)
579
+ next unless v
580
+ case k
581
+ when 'color'
582
+ color = parse_hex_color(v)
583
+ when 'rect'
584
+ parts = v.split(',').map(&:to_i)
585
+ rect = parts if parts.size == 4
586
+ end
587
+ end
588
+ return nil unless color && rect
589
+ {rect: rect, color: color}
590
+ end
591
+
592
+ # Accepts "#rrggbb", "#rgb", or "#rrggbbaa". Returns [r, g, b, a]
593
+ # in 0.0..1.0, or nil on parse failure.
594
+ def parse_hex_color(hex)
595
+ str = hex.delete_prefix('#')
596
+ case str.length
597
+ when 3
598
+ r, g, b = str.chars.map { |c| (c * 2).to_i(16) / 255.0 }
599
+ [r, g, b, 1.0]
600
+ when 6
601
+ r = str[0, 2].to_i(16) / 255.0
602
+ g = str[2, 2].to_i(16) / 255.0
603
+ b = str[4, 2].to_i(16) / 255.0
604
+ [r, g, b, 1.0]
605
+ when 8
606
+ r = str[0, 2].to_i(16) / 255.0
607
+ g = str[2, 2].to_i(16) / 255.0
608
+ b = str[4, 2].to_i(16) / 255.0
609
+ a = str[6, 2].to_i(16) / 255.0
610
+ [r, g, b, a]
611
+ end
612
+ end
613
+
614
+ def dispatch_osc133(rest)
615
+ return unless @screen.respond_to?(:osc133_mark)
616
+ parts = rest.split(';')
617
+ case parts[0]
618
+ when 'A' then @screen.osc133_mark(:prompt_start)
619
+ when 'B' then @screen.osc133_mark(:prompt_end)
620
+ when 'C' then @screen.osc133_mark(:command_start)
621
+ when 'D' then @screen.osc133_mark(:command_end, exit_code: parts[1]&.to_i)
622
+ end
623
+ end
624
+
625
+ def dispatch_osc52(rest)
626
+ _selection, data = rest.split(';', 2)
627
+ return unless data
628
+
629
+ if data == '?'
630
+ # Query clipboard — respond with current clipboard content
631
+ if @writer && @screen.respond_to?(:clipboard_content)
632
+ content = @screen.clipboard_content
633
+ if content
634
+ encoded = [content].pack('m0')
635
+ @writer.call("\e]52;c;#{encoded}\e\\")
636
+ end
637
+ end
638
+ else
639
+ # Set clipboard
640
+ decoded = data.unpack1('m')
641
+ decoded.force_encoding('UTF-8')
642
+ @screen.set_clipboard(decoded) if @screen.respond_to?(:set_clipboard)
643
+ end
644
+ end
645
+
646
+ def dispatch_csi(final)
647
+ if @csi_prefix
648
+ if @csi_prefix == 0x3E && final == 'c'
649
+ dispatch_da2(collect_params)
650
+ end
651
+ return
652
+ end
653
+
654
+ params = collect_params
655
+
656
+ case final
657
+ when 'A' then @screen.move_cursor_up(params[0] || 1)
658
+ when 'B' then @screen.move_cursor_down(params[0] || 1)
659
+ when 'C' then @screen.move_cursor_forward(params[0] || 1)
660
+ when 'D' then @screen.move_cursor_backward(params[0] || 1)
661
+ when 'E' then @screen.move_cursor_next_line(params[0] || 1)
662
+ when 'F' then @screen.move_cursor_prev_line(params[0] || 1)
663
+ when 'H', 'f' then @screen.move_cursor((params[0] || 1) - 1, (params[1] || 1) - 1)
664
+ when 'G', '`' then @screen.move_cursor(@screen.cursor.row, (params[0] || 1) - 1)
665
+ when 'd' then @screen.move_cursor((params[0] || 1) - 1, @screen.cursor.col)
666
+ when 'J' then @screen.erase_in_display(params[0] || 0)
667
+ when 'K' then @screen.erase_in_line(params[0] || 0)
668
+ when 'L' then @screen.insert_lines(params[0] || 1)
669
+ when 'M' then @screen.delete_lines(params[0] || 1)
670
+ when 'P' then @screen.delete_chars(params[0] || 1)
671
+ when '@' then @screen.insert_chars(params[0] || 1)
672
+ when 'X' then @screen.erase_chars(params[0] || 1)
673
+ when 'Z' then @screen.backward_tab(params[0] || 1)
674
+ when 'b' then @screen.repeat_char(params[0] || 1)
675
+ when 'S' then @screen.scroll_up(params[0] || 1)
676
+ when 'T' then @screen.scroll_down(params[0] || 1)
677
+ when 'm' then @screen.set_graphics(collect_sgr_params)
678
+ when 'r' then @screen.set_scroll_region((params[0] || 1) - 1, (params[1] || @screen.rows) - 1)
679
+ when 's' then @screen.save_cursor
680
+ when 'u' then @screen.restore_cursor
681
+ when 'g' then @screen.clear_tab_stop(params[0] || 0)
682
+ when 'c' then dispatch_da(params)
683
+ when 'n' then dispatch_dsr(params)
684
+ when 'p' then @screen.soft_reset if @csi_intermediate == '!'
685
+ when 'q' then @screen.cursor_style = (params[0] || 0) if @csi_intermediate == ' '
686
+ when 't' then dispatch_window_ops(params)
687
+ when 'h' then dispatch_mode_set(params)
688
+ when 'l' then dispatch_mode_reset(params)
689
+ end
690
+ end
691
+
692
+ def dispatch_mode_set(params)
693
+ unless @private_flag
694
+ params.each do |p|
695
+ case p
696
+ when 4 then @screen.insert_mode = true
697
+ end
698
+ end
699
+ return
700
+ end
701
+
702
+ params.each do |p|
703
+ case p
704
+ when 1 then @screen.application_cursor_keys = true
705
+ when 6 then @screen.origin_mode = true
706
+ when 7 then @screen.auto_wrap = true
707
+ when 25 then @screen.show_cursor
708
+ when 9 then @screen.mouse_tracking = :x10
709
+ when 1000 then @screen.mouse_tracking = :normal
710
+ when 1002 then @screen.mouse_tracking = :button_event
711
+ when 1003 then @screen.mouse_tracking = :any_event
712
+ when 1006 then @screen.mouse_encoding = :sgr
713
+ when 1004 then @screen.focus_reporting = true
714
+ when 2004 then @screen.bracketed_paste_mode = true
715
+ when 1049
716
+ @screen.save_cursor
717
+ @screen.switch_to_alt_screen
718
+ when 47, 1047
719
+ @screen.switch_to_alt_screen
720
+ end
721
+ end
722
+ end
723
+
724
+ def dispatch_mode_reset(params)
725
+ unless @private_flag
726
+ params.each do |p|
727
+ case p
728
+ when 4 then @screen.insert_mode = false
729
+ end
730
+ end
731
+ return
732
+ end
733
+
734
+ params.each do |p|
735
+ case p
736
+ when 1 then @screen.application_cursor_keys = false
737
+ when 6 then @screen.origin_mode = false
738
+ when 7 then @screen.auto_wrap = false
739
+ when 25 then @screen.hide_cursor
740
+ when 9, 1000, 1002, 1003 then @screen.mouse_tracking = :off
741
+ when 1006 then @screen.mouse_encoding = :default
742
+ when 1004 then @screen.focus_reporting = false
743
+ when 2004 then @screen.bracketed_paste_mode = false
744
+ when 1049
745
+ @screen.switch_to_main_screen
746
+ @screen.restore_cursor
747
+ when 47, 1047
748
+ @screen.switch_to_main_screen
749
+ end
750
+ end
751
+ end
752
+
753
+ def dispatch_da(params)
754
+ return unless @writer
755
+ return unless params[0].nil? || params[0] == 0
756
+
757
+ # VT220 with ANSI color support
758
+ @writer.call("\e[?62;22c")
759
+ end
760
+
761
+ def dispatch_da2(params)
762
+ return unless @writer
763
+ return unless params[0].nil? || params[0] == 0
764
+
765
+ # Report as VT220 (type 1), version 0.1.0 → 100, ROM cartridge 0
766
+ @writer.call("\e[>1;100;0c")
767
+ end
768
+
769
+ def dispatch_dsr(params)
770
+ return unless @writer
771
+
772
+ case params[0]
773
+ when 5
774
+ @writer.call("\e[0n")
775
+ when 6
776
+ row = @screen.cursor.row + 1
777
+ col = @screen.cursor.col + 1
778
+ @writer.call("\e[#{row};#{col}R")
779
+ end
780
+ end
781
+
782
+ def dispatch_window_ops(params)
783
+ case params[0]
784
+ when 14
785
+ # Report window size in pixels
786
+ if @writer
787
+ px_height = (@screen.rows * @screen.cell_pixel_height).to_i
788
+ px_width = (@screen.cols * @screen.cell_pixel_width).to_i
789
+ @writer.call("\e[4;#{px_height};#{px_width}t")
790
+ end
791
+ when 18
792
+ # Report text area size in characters
793
+ @writer&.call("\e[8;#{@screen.rows};#{@screen.cols}t")
794
+ when 22
795
+ # Push title
796
+ @screen.push_title if params[1] == 0 || params[1] == 2 || params[1].nil?
797
+ when 23
798
+ # Pop title
799
+ @screen.pop_title if params[1] == 0 || params[1] == 2 || params[1].nil?
800
+ end
801
+ end
802
+
803
+ def collect_params
804
+ raw = @params + [@current_param]
805
+ raw.map { |s| s.empty? ? nil : s.to_i }
806
+ end
807
+
808
+ # For SGR (m), return params that may contain colon sub-parameters.
809
+ # Each element is either an Integer or an Array of (Integer|nil) for sub-params.
810
+ def collect_sgr_params
811
+ raw = @params + [@current_param]
812
+ raw.map do |s|
813
+ if s.include?(':')
814
+ s.split(':').map { |p| p.empty? ? nil : p.to_i }
815
+ else
816
+ s.empty? ? nil : s.to_i
817
+ end
818
+ end
819
+ end
820
+ end
821
+ end