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.
- checksums.yaml +7 -0
- data/CLAUDE.md +33 -0
- data/Echoes.app/Contents/Info.plist +16 -0
- data/Echoes.app/Contents/MacOS/Echoes +50 -0
- data/EchoesEmbed.app/Contents/Info.plist +16 -0
- data/EchoesEmbed.app/Contents/MacOS/EchoesEmbed +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +45 -0
- data/exe/echoes +15 -0
- data/lib/echoes/cell.rb +54 -0
- data/lib/echoes/client.rb +96 -0
- data/lib/echoes/configuration.rb +135 -0
- data/lib/echoes/copy_mode.rb +545 -0
- data/lib/echoes/cursor.rb +18 -0
- data/lib/echoes/editor.rb +225 -0
- data/lib/echoes/embedded_shell.rb +360 -0
- data/lib/echoes/embedded_shell_helper.rb +265 -0
- data/lib/echoes/gui.rb +2861 -0
- data/lib/echoes/installer.rb +95 -0
- data/lib/echoes/objc.rb +188 -0
- data/lib/echoes/pane.rb +1122 -0
- data/lib/echoes/pane_tree.rb +194 -0
- data/lib/echoes/parser.rb +821 -0
- data/lib/echoes/preferences.rb +45 -0
- data/lib/echoes/screen.rb +1468 -0
- data/lib/echoes/sixel_decoder.rb +221 -0
- data/lib/echoes/tab.rb +152 -0
- data/lib/echoes/terminal.rb +124 -0
- data/lib/echoes/version.rb +5 -0
- data/lib/echoes.rb +37 -0
- data/sig/echoes.rbs +4 -0
- metadata +123 -0
|
@@ -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
|