tarazed 0.1.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,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "shellwords"
5
+
6
+ module Tarazed
7
+ class PTY
8
+ attr_reader :reader, :writer, :pid, :grid, :vt, :status, :initial_cwd, :command_name
9
+
10
+ def initialize(command: ENV.fetch("SHELL", "/bin/sh"), cwd: Dir.pwd, columns: 80, rows: 24, env: {},
11
+ scrollback: 10_000, queue_limit_bytes: 8_388_608)
12
+ unless queue_limit_bytes.is_a?(Integer) && queue_limit_bytes.positive?
13
+ raise ArgumentError, "terminal queue limit must be a positive integer"
14
+ end
15
+
16
+ @initial_cwd = File.expand_path(cwd)
17
+ @command_name = File.basename(Array(command).first.to_s)
18
+ @grid = Grid.new(columns: columns, rows: rows, scrollback: scrollback)
19
+ if Gem.win_platform?
20
+ require_relative "windows/conpty"
21
+ command = nil if command == "/bin/sh"
22
+ @native = Windows::ConPTY.new(command: command, cwd: cwd, columns: columns, rows: rows, env: env)
23
+ @pid = @native.pid
24
+ @vt = VT.new(grid) { |bytes| write(bytes) }
25
+ return
26
+ end
27
+
28
+ require "pty"
29
+ arguments = command.is_a?(Array) ? command : Shellwords.split(command)
30
+ raise ArgumentError, "terminal command required" if arguments.empty?
31
+
32
+ @reader, @writer, @pid = ::PTY.spawn({"TERM" => "xterm-256color", "COLORTERM" => "truecolor"}.merge(env),
33
+ *arguments, chdir: cwd)
34
+ @reader.binmode
35
+ @writer.binmode
36
+ @writer.sync = true
37
+ @vt = VT.new(grid) { |bytes| write(bytes) }
38
+ resize(columns: columns, rows: rows)
39
+ @queue, @queue_bytes, @queue_limit_bytes = [], 0, queue_limit_bytes
40
+ @queue_lock, @queue_ready = Mutex.new, ConditionVariable.new
41
+ start_reader
42
+ end
43
+
44
+ # nil means EOF; an empty String means that no data was ready yet.
45
+ def read(timeout: 0, max_bytes: 65_536, max_seconds: nil)
46
+ unless max_bytes.is_a?(Integer) && max_bytes.positive?
47
+ raise ArgumentError, "terminal read limit must be a positive integer"
48
+ end
49
+ raise ArgumentError, "terminal read timeout must be nonnegative" unless timeout.is_a?(Numeric) && timeout >= 0
50
+ unless max_seconds.nil? || max_seconds.is_a?(Numeric) && max_seconds >= 0
51
+ raise ArgumentError, "terminal parse budget must be nonnegative"
52
+ end
53
+ return nil if @eof
54
+ if @native
55
+ data = @native.read_available(limit: max_bytes)
56
+ data ? vt.feed(data) : @eof = true
57
+ return data
58
+ end
59
+
60
+ deadline = max_seconds && Process.clock_gettime(Process::CLOCK_MONOTONIC) + max_seconds
61
+ data = +"".b
62
+ loop do
63
+ break if data.bytesize >= max_bytes || deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
64
+
65
+ piece = @queue_lock.synchronize do
66
+ if data.empty? && @queue.empty? && !@reader_eof && timeout.positive?
67
+ @queue_ready.wait(@queue_lock, timeout)
68
+ end
69
+ if @queue.empty?
70
+ @eof = true if @reader_eof
71
+ nil
72
+ else
73
+ chunk = @queue.shift
74
+ take = [max_bytes - data.bytesize, chunk.bytesize, 16_384].min
75
+ result = chunk.byteslice(0, take)
76
+ @queue.unshift(chunk.byteslice(take..)) if take < chunk.bytesize
77
+ @queue_bytes -= take
78
+ @queue_ready.broadcast
79
+ result
80
+ end
81
+ end
82
+ break unless piece
83
+
84
+ vt.feed(piece)
85
+ data << piece
86
+ end
87
+ @eof && data.empty? ? nil : data
88
+ end
89
+
90
+ def pending? = @native ? @native.pending? : @queue_lock.synchronize { !@queue.empty? }
91
+ def write(bytes) = @native ? @native.write(bytes) : writer.write(bytes)
92
+ def paste(text) = write(vt.paste(text))
93
+ def key(name, **modifiers) = write(vt.key(name, **modifiers))
94
+ def mouse(**event) = write(vt.mouse(**event))
95
+
96
+ def resize(columns:, rows:)
97
+ dimensions = [columns, rows]
98
+ return self if @pty_size == dimensions
99
+
100
+ grid.resize(columns: columns, rows: rows) unless grid.columns == columns && grid.rows == rows
101
+ @native ? @native.resize(columns, rows) : reader.winsize = [rows, columns]
102
+ @pty_size = dimensions
103
+ self
104
+ end
105
+
106
+ def busy?
107
+ !@native && alive? && reader.tcgetpgrp != pid
108
+ rescue IOError, SystemCallError, NoMethodError
109
+ false
110
+ end
111
+
112
+ def foreground_process_name(now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
113
+ return @foreground_name if @foreground_checked && now - @foreground_checked < 1
114
+
115
+ @foreground_checked = now
116
+ group = reader.tcgetpgrp unless @native
117
+ value = if group && group != pid && File.file?("/proc/#{group}/comm")
118
+ File.read("/proc/#{group}/comm")
119
+ elsif group && group != pid
120
+ IO.popen(["ps", "-o", "comm=", "-p", group.to_s], &:read)
121
+ end
122
+ @foreground_name = value&.strip&.then { |name| File.basename(name) unless name.empty? }
123
+ rescue IOError, SystemCallError, NoMethodError
124
+ @foreground_name = nil
125
+ end
126
+
127
+ def signal(name = "INT")
128
+ return @native.write("\x03") if @native && name == "INT"
129
+ if @native
130
+ @native.close
131
+ return false
132
+ end
133
+
134
+ Process.kill(name, -pid)
135
+ rescue Errno::ESRCH, Errno::EPERM
136
+ begin
137
+ Process.kill(name, pid)
138
+ rescue Errno::ESRCH, Errno::EPERM
139
+ false
140
+ end
141
+ end
142
+
143
+ def alive?
144
+ return @native.alive? if @native
145
+ return false if @status
146
+
147
+ result = Process.waitpid2(pid, Process::WNOHANG)
148
+ @status = result.last if result
149
+ !result
150
+ rescue Errno::ECHILD
151
+ false
152
+ end
153
+
154
+ def close
155
+ if @native
156
+ @native.close
157
+ @eof = true
158
+ return self
159
+ end
160
+
161
+ @closing = true
162
+ @queue_lock.synchronize { @queue_ready.broadcast }
163
+ signal("HUP") if alive?
164
+ reader.close unless reader.closed?
165
+ writer.close unless writer.closed?
166
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
167
+ sleep 0.01 while alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
168
+ if alive?
169
+ signal("KILL")
170
+ _, @status = Process.waitpid2(pid)
171
+ end
172
+ @reader_thread&.join(0.2)
173
+ @eof = true
174
+ self
175
+ rescue Errno::ECHILD, IOError
176
+ self
177
+ end
178
+
179
+ private
180
+
181
+ def start_reader
182
+ @reader_thread = Thread.new do
183
+ loop do
184
+ data = reader.readpartial(65_536)
185
+ @queue_lock.synchronize do
186
+ @queue_ready.wait(@queue_lock) while !@closing && @queue_bytes >= @queue_limit_bytes
187
+ break if @closing
188
+
189
+ @queue << data
190
+ @queue_bytes += data.bytesize
191
+ @queue_ready.broadcast
192
+ end
193
+ end
194
+ rescue Errno::EIO, EOFError, IOError
195
+ nil
196
+ ensure
197
+ @queue_lock.synchronize do
198
+ @reader_eof = true
199
+ @queue_ready.broadcast
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarazed
4
+ class Scrollback
5
+ include Enumerable
6
+
7
+ attr_reader :limit
8
+
9
+ def initialize(limit)
10
+ raise ArgumentError, "scrollback limit must be nonnegative" unless limit.is_a?(Integer) && limit >= 0
11
+
12
+ @limit = limit
13
+ clear
14
+ end
15
+
16
+ def clear = @rows = []
17
+ def length = @rows.length
18
+ alias size length
19
+ def [](index) = @rows[index]
20
+ def each(&block) = block ? @rows.each(&block) : enum_for(__method__)
21
+
22
+ def push(cells)
23
+ return if limit.zero?
24
+
25
+ @rows.shift if @rows.length == limit
26
+ @rows << cells.map { |cell| cell.dup.tap { |copy| copy.text = copy.text.dup.freeze }.freeze }.freeze
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarazed
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tarazed/vt.rb ADDED
@@ -0,0 +1,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Tarazed
6
+ class VT
7
+ DEC_GRAPHICS = {"`" => "◆", "a" => "▒", "f" => "°", "g" => "±", "j" => "┘", "k" => "┐",
8
+ "l" => "┌", "m" => "└", "n" => "┼", "o" => "⎺", "p" => "⎻", "q" => "─", "r" => "⎼", "s" => "⎽",
9
+ "t" => "├", "u" => "┤", "v" => "┴", "w" => "┬", "x" => "│", "y" => "≤", "z" => "≥", "{" => "π",
10
+ "|" => "≠", "}" => "£", "~" => "·"}.freeze
11
+
12
+ attr_reader :grid, :title, :cwd, :modes, :replies, :bell_count
13
+
14
+ def initialize(grid = Grid.new, &reply)
15
+ @grid = grid
16
+ @reply = reply
17
+ @replies = []
18
+ @input = +"".b
19
+ @state = :ground
20
+ @sequence = +"".b
21
+ @modes = {7 => true, 25 => true}
22
+ @charsets = [:ascii, :ascii]
23
+ @charset = 0
24
+ @bell_count = 0
25
+ end
26
+
27
+ # The parser retains incomplete UTF-8 and control sequences between reads.
28
+ def feed(bytes)
29
+ @input << bytes.b
30
+ if !@input.empty? && @state == :ground && @charsets[@charset] == :ascii && @input.ascii_only? &&
31
+ !@input.match?(/[\x00-\x1f\x7f]/)
32
+ grid.put_ascii(@input)
33
+ @last_printed = @input.getbyte(-1).chr
34
+ @input.clear
35
+ return self
36
+ end
37
+ index = 0
38
+ while index < @input.bytesize
39
+ byte = @input.getbyte(index)
40
+ if @state == :ground && @charsets[@charset] == :ascii && byte.between?(0x20, 0x7e)
41
+ finish = index + 1
42
+ finish += 1 while finish < @input.bytesize && @input.getbyte(finish).between?(0x20, 0x7e)
43
+ grid.put_ascii(@input.byteslice(index...finish))
44
+ @last_printed = @input.getbyte(finish - 1).chr
45
+ index = finish
46
+ next
47
+ end
48
+ if @state == :ground && byte >= 0xa0
49
+ length = if byte.between?(0xc2, 0xdf)
50
+ 2
51
+ elsif byte.between?(0xe0, 0xef)
52
+ 3
53
+ elsif byte.between?(0xf0, 0xf4)
54
+ 4
55
+ else
56
+ 1
57
+ end
58
+ break if index + length > @input.bytesize
59
+
60
+ char = @input.byteslice(index, length).force_encoding(Encoding::UTF_8)
61
+ unless char.valid_encoding?
62
+ char = "\ufffd"
63
+ length = 1
64
+ end
65
+ print(char)
66
+ index += length
67
+ next
68
+ end
69
+ consume(byte)
70
+ index += 1
71
+ end
72
+ @input = @input.byteslice(index..) || +"".b
73
+ self
74
+ end
75
+
76
+ def paste(text)
77
+ modes[2004] ? "\e[200~#{text.gsub("\e[201~", "")}\e[201~" : text
78
+ end
79
+
80
+ def mouse(button:, column:, row:, action: :press, shift: false, alt: false, control: false)
81
+ tracking = [1000, 1002, 1003].find { |mode| modes[mode] }
82
+ return "" unless tracking
83
+ return "" if action == :move && (tracking == 1000 || (tracking == 1002 && button.nil?))
84
+
85
+ code = case button
86
+ when :left, 0 then 0
87
+ when :middle, 1 then 1
88
+ when :right, 2 then 2
89
+ when :wheel_up then 64
90
+ when :wheel_down then 65
91
+ else 3
92
+ end
93
+ code |= 32 if action == :move
94
+ code |= 4 if shift
95
+ code |= 8 if alt
96
+ code |= 16 if control
97
+ column = [column + 1, 1].max
98
+ row = [row + 1, 1].max
99
+ if modes[1006]
100
+ "\e[<#{code};#{column};#{row}#{action == :release ? 'm' : 'M'}"
101
+ else
102
+ code = (code & ~3) | 3 if action == :release
103
+ return "" if column > 223 || row > 223
104
+
105
+ "\e[M".b + [code + 32, column + 32, row + 32].pack("C3")
106
+ end
107
+ end
108
+
109
+ def key(name, shift: false, alt: false, control: false)
110
+ name = name.to_s
111
+ modifier = 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (control ? 4 : 0)
112
+ arrows = {"up" => "A", "down" => "B", "right" => "C", "left" => "D", "home" => "H", "end" => "F"}
113
+ if arrows.key?(name)
114
+ return modifier > 1 ? "\e[1;#{modifier}#{arrows[name]}" : "\e#{modes[1] ? 'O' : '['}#{arrows[name]}"
115
+ end
116
+ function = {"insert" => 2, "delete" => 3, "page_up" => 5, "page_down" => 6,
117
+ "f5" => 15, "f6" => 17, "f7" => 18, "f8" => 19, "f9" => 20, "f10" => 21, "f11" => 23, "f12" => 24}
118
+ return "\e[#{function[name]}#{modifier > 1 ? ";#{modifier}" : ''}~" if function.key?(name)
119
+
120
+ if /\Af[1-4]\z/.match?(name)
121
+ final = ("P".ord + name[1].to_i - 1).chr
122
+ return modifier > 1 ? "\e[1;#{modifier}#{final}" : "\eO#{final}"
123
+ end
124
+ return "\e[Z" if name == "tab" && shift
125
+
126
+ text = {"enter" => "\r", "backspace" => "\x7f", "tab" => "\t", "escape" => "\e"}.fetch(name, name)
127
+ text = (text.upcase.ord & 0x1f).chr if control && text.length == 1 && ("@".."_").cover?(text.upcase)
128
+ text = "\0" if control && text == " "
129
+ alt ? "\e#{text}" : text
130
+ end
131
+
132
+ private
133
+
134
+ def consume(byte)
135
+ return consume_string(byte) if [:osc, :dcs, :string].include?(@state)
136
+
137
+ if byte == 0x1b
138
+ @state = :escape
139
+ @sequence.clear
140
+ return
141
+ elsif [0x18, 0x1a].include?(byte)
142
+ @state = :ground
143
+ return
144
+ elsif byte < 0x20 || byte == 0x7f
145
+ control(byte)
146
+ return
147
+ end
148
+ case @state
149
+ when :ground
150
+ case byte
151
+ when 0x9b then start_sequence(:csi)
152
+ when 0x9d then start_sequence(:osc)
153
+ when 0x90 then start_sequence(:dcs)
154
+ when 0x84 then grid.linefeed
155
+ when 0x85 then grid.carriage_return; grid.linefeed
156
+ when 0x8d then grid.reverse_index
157
+ else print(byte.chr) if byte < 0x80
158
+ end
159
+ when :escape
160
+ case byte.chr
161
+ when "[" then start_sequence(:csi)
162
+ when "]" then start_sequence(:osc)
163
+ when "P" then start_sequence(:dcs)
164
+ when "_", "^", "X" then start_sequence(:string)
165
+ when "(" then @charset_target = 0; @state = :charset
166
+ when ")" then @charset_target = 1; @state = :charset
167
+ when "#", "%" then @state = :escape_intermediate; @sequence << byte
168
+ else
169
+ escape(byte.chr)
170
+ @state = :ground
171
+ end
172
+ when :escape_intermediate
173
+ if @sequence == "#" && byte == "8".ord
174
+ grid.cells.each { |row| row.each { |cell| cell.text = "E"; cell.width = 1 } }
175
+ end
176
+ @state = :ground
177
+ when :charset
178
+ @charsets[@charset_target] = byte == "0".ord ? :graphics : :ascii
179
+ @state = :ground
180
+ when :csi
181
+ if byte.between?(0x40, 0x7e)
182
+ csi(byte.chr, @sequence)
183
+ @state = :ground
184
+ elsif @sequence.bytesize < 1024 && byte.between?(0x20, 0x3f)
185
+ @sequence << byte
186
+ else
187
+ @state = :csi_ignore
188
+ end
189
+ when :csi_ignore
190
+ @state = :ground if byte.between?(0x40, 0x7e)
191
+ end
192
+ end
193
+
194
+ def start_sequence(state)
195
+ @state = state
196
+ @sequence = +"".b
197
+ @string_escape = @string_overflow = false
198
+ @string_utf8_remaining = 0
199
+ end
200
+
201
+ def consume_string(byte)
202
+ if @string_utf8_remaining.positive? && byte.between?(0x80, 0xbf)
203
+ @string_utf8_remaining -= 1
204
+ append_string(byte)
205
+ return
206
+ end
207
+ @string_utf8_remaining = if byte.between?(0xc2, 0xdf)
208
+ 1
209
+ elsif byte.between?(0xe0, 0xef)
210
+ 2
211
+ elsif byte.between?(0xf0, 0xf4)
212
+ 3
213
+ else
214
+ 0
215
+ end
216
+ if byte == 0x9c || (@string_escape && byte == 0x5c) || (byte == 7 && @state == :osc)
217
+ unless @string_overflow
218
+ osc(@sequence) if @state == :osc
219
+ dcs(@sequence) if @state == :dcs
220
+ end
221
+ @state = :ground
222
+ @string_escape = false
223
+ elsif [0x18, 0x1a].include?(byte)
224
+ @state = :ground
225
+ elsif byte == 0x1b
226
+ @string_escape = true
227
+ else
228
+ @sequence << 0x1b if @string_escape && !@string_overflow
229
+ @string_escape = false
230
+ append_string(byte)
231
+ end
232
+ end
233
+
234
+ def append_string(byte)
235
+ @sequence.bytesize < 16_384 ? @sequence << byte : @string_overflow = true
236
+ end
237
+
238
+ def control(byte)
239
+ case byte
240
+ when 7 then @bell_count += 1
241
+ when 8 then grid.backspace
242
+ when 9 then grid.tab
243
+ when 10, 11, 12 then grid.linefeed; grid.carriage_return if modes[20]
244
+ when 13 then grid.carriage_return
245
+ when 14 then @charset = 1
246
+ when 15 then @charset = 0
247
+ end
248
+ end
249
+
250
+ def escape(char)
251
+ case char
252
+ when "7" then grid.save_cursor
253
+ when "8" then grid.restore_cursor
254
+ when "D" then grid.linefeed
255
+ when "E" then grid.carriage_return; grid.linefeed
256
+ when "M" then grid.reverse_index
257
+ when "H" then grid.tab_set
258
+ when "=" then @modes[:keypad] = true
259
+ when ">" then @modes[:keypad] = false
260
+ when "c"
261
+ grid.reset
262
+ @modes = {7 => true, 25 => true}
263
+ @charsets = [:ascii, :ascii]
264
+ @charset = 0
265
+ end
266
+ end
267
+
268
+ def print(char)
269
+ char = DEC_GRAPHICS.fetch(char, char) if @charsets[@charset] == :graphics
270
+ grid.put(char)
271
+ @last_printed = char
272
+ end
273
+
274
+ def csi(final, sequence)
275
+ prefix = sequence[/\A[?<=>]/]
276
+ parameters = sequence.sub(/\A[?<=>]/, "").sub(/[\x20-\x2f]+\z/, "")
277
+ values = parameters.split(";", -1).map { |value| value.empty? ? 0 : value.to_i.clamp(0, 1_000_000) }
278
+ values = [0] if values.empty?
279
+ amount = values.first.zero? ? 1 : values.first
280
+ case final
281
+ when "A" then grid.move(y: grid.cursor_y - amount)
282
+ when "B", "e" then grid.move(y: grid.cursor_y + amount)
283
+ when "C", "a" then grid.move(x: grid.cursor_x + amount)
284
+ when "D" then grid.move(x: grid.cursor_x - amount)
285
+ when "E" then grid.move(x: 0, y: grid.cursor_y + amount)
286
+ when "F" then grid.move(x: 0, y: grid.cursor_y - amount)
287
+ when "G", "`" then grid.move(x: amount - 1)
288
+ when "H", "f" then grid.position(amount, values[1].to_i.zero? ? 1 : values[1])
289
+ when "d" then grid.position(amount, grid.cursor_x + 1)
290
+ when "I" then grid.tab([amount, grid.columns].min)
291
+ when "Z" then grid.tab([amount, grid.columns].min, backward: true)
292
+ when "J" then grid.erase_display(values.first)
293
+ when "K" then grid.erase_line(values.first)
294
+ when "L" then grid.insert_lines(amount)
295
+ when "M" then grid.delete_lines(amount)
296
+ when "P" then grid.delete_characters(amount)
297
+ when "@" then grid.insert_characters(amount)
298
+ when "X" then grid.erase_characters(amount)
299
+ when "S" then grid.scroll_up(amount)
300
+ when "T" then grid.scroll_down(amount) unless values.length > 1
301
+ when "r" then grid.margins(amount, values[1].to_i.zero? ? grid.rows : values[1]) unless prefix
302
+ when "s" then grid.save_cursor
303
+ when "u" then grid.restore_cursor
304
+ when "g" then grid.tab_clear(all: values.first == 3)
305
+ when "m" then sgr(parameters) unless prefix
306
+ when "h", "l" then values.each { |mode| set_mode(mode, final == "h", prefix == "?") }
307
+ when "b"
308
+ [amount, grid.columns * grid.rows].min.times { grid.put(@last_printed) } if @last_printed
309
+ when "n"
310
+ emit("\e[0n") if values.first == 5
311
+ emit("\e[#{prefix == '?' ? '?' : ''}#{grid.cursor_y + 1};#{grid.cursor_x + 1}R") if values.first == 6
312
+ when "c"
313
+ emit(prefix == ">" ? "\e[>0;1;0c" : "\e[?1;2c")
314
+ when "t"
315
+ emit("\e[8;#{grid.rows};#{grid.columns}t") if values.first == 18
316
+ emit("\e]l#{title}\e\\") if values.first == 21
317
+ when "p"
318
+ if sequence.end_with?("!")
319
+ grid.attributes = {}.freeze
320
+ grid.foreground = grid.background = nil
321
+ grid.insert_mode = grid.origin_mode = false
322
+ grid.autowrap = grid.cursor_visible = true
323
+ grid.margins
324
+ elsif sequence.end_with?("$")
325
+ state = modes.key?(values.first) ? modes[values.first] ? 1 : 2 : 0
326
+ emit("\e[?#{values.first};#{state}$y")
327
+ end
328
+ end
329
+ end
330
+
331
+ def set_mode(mode, enabled, private_mode)
332
+ @modes[mode] = enabled
333
+ if private_mode
334
+ case mode
335
+ when 6 then grid.origin_mode = enabled; grid.position(1, 1)
336
+ when 7 then grid.autowrap = enabled
337
+ when 25 then grid.cursor_visible = enabled
338
+ when 47, 1047 then grid.alternate(enabled, save: false)
339
+ when 1048 then enabled ? grid.save_cursor : grid.restore_cursor
340
+ when 1049 then grid.alternate(enabled)
341
+ when 1000, 1002, 1003
342
+ [1000, 1002, 1003].each { |other| @modes[other] = false unless other == mode } if enabled
343
+ end
344
+ elsif mode == 4
345
+ grid.insert_mode = enabled
346
+ end
347
+ end
348
+
349
+ def sgr(parameters)
350
+ groups = parameters.empty? ? ["0"] : parameters.split(";", -1)
351
+ attributes = grid.attributes.dup
352
+ index = 0
353
+ while index < groups.length
354
+ parts = groups[index].split(":", -1)
355
+ value = parts.first.to_i
356
+ case value
357
+ when 0 then attributes.clear; grid.foreground = grid.background = nil
358
+ when 1 then attributes[:bold] = true
359
+ when 2 then attributes[:dim] = true
360
+ when 3 then attributes[:italic] = true
361
+ when 4 then attributes[:underline] = parts[1] ? parts[1].to_i : 1
362
+ when 5, 6 then attributes[:blink] = true
363
+ when 7 then attributes[:inverse] = true
364
+ when 8 then attributes[:hidden] = true
365
+ when 9 then attributes[:strikethrough] = true
366
+ when 21 then attributes[:underline] = 2
367
+ when 22 then attributes.delete(:bold); attributes.delete(:dim)
368
+ when 23 then attributes.delete(:italic)
369
+ when 24 then attributes.delete(:underline)
370
+ when 25 then attributes.delete(:blink)
371
+ when 27 then attributes.delete(:inverse)
372
+ when 28 then attributes.delete(:hidden)
373
+ when 29 then attributes.delete(:strikethrough)
374
+ when 30..37 then grid.foreground = value - 30
375
+ when 40..47 then grid.background = value - 40
376
+ when 90..97 then grid.foreground = value - 90 + 8
377
+ when 100..107 then grid.background = value - 100 + 8
378
+ when 39 then grid.foreground = nil
379
+ when 49 then grid.background = nil
380
+ when 38, 48, 58
381
+ color = color_parameter(parts, groups, index)
382
+ index += color[:consumed]
383
+ if color[:value]
384
+ case value
385
+ when 38 then grid.foreground = color[:value]
386
+ when 48 then grid.background = color[:value]
387
+ else attributes[:underline_color] = color[:value]
388
+ end
389
+ end
390
+ when 59 then attributes.delete(:underline_color)
391
+ end
392
+ index += 1
393
+ end
394
+ grid.attributes = attributes.freeze
395
+ end
396
+
397
+ def color_parameter(parts, groups, index)
398
+ consumed = 0
399
+ color = if parts.length > 1
400
+ kind = parts[1].to_i
401
+ kind == 5 ? parts[2]&.to_i : kind == 2 && parts.length >= 5 ? parts.last(3).map(&:to_i) : nil
402
+ else
403
+ kind = groups[index + 1]&.to_i
404
+ count = kind == 5 ? 1 : kind == 2 ? 3 : 0
405
+ if count.positive? && index + 1 + count < groups.length
406
+ consumed = count + 1
407
+ count == 1 ? groups[index + 2].to_i : groups[index + 2, count].map(&:to_i)
408
+ end
409
+ end
410
+ color = color.map { |part| part.clamp(0, 255) }.freeze if color.is_a?(Array)
411
+ color = color.clamp(0, 255) if color.is_a?(Integer)
412
+ {value: color, consumed: consumed}
413
+ end
414
+
415
+ def osc(sequence)
416
+ command, payload = sequence.force_encoding(Encoding::UTF_8).scrub.split(";", 2)
417
+ return unless payload
418
+
419
+ case command
420
+ when "0", "2" then @title = payload
421
+ when "7"
422
+ uri = URI.parse(payload)
423
+ @cwd = URI.decode_uri_component(uri.path) if uri.scheme == "file" && uri.path&.start_with?("/")
424
+ when "8"
425
+ _, url = payload.split(";", 2)
426
+ grid.hyperlink = url.nil? || url.empty? ? nil : url.freeze
427
+ end
428
+ rescue URI::InvalidURIError, ArgumentError
429
+ nil
430
+ end
431
+
432
+ def dcs(sequence)
433
+ return unless sequence.start_with?("$q")
434
+
435
+ request = sequence.delete_prefix("$q")
436
+ answer = {"m" => "0m", "r" => "#{grid.scroll_top + 1};#{grid.scroll_bottom + 1}r"}[request]
437
+ emit("\eP#{answer ? 1 : 0}$r#{answer || request}\e\\")
438
+ end
439
+
440
+ def emit(bytes)
441
+ @reply ? @reply.call(bytes) : @replies << bytes
442
+ end
443
+ end
444
+ end