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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +55 -0
- data/docs/adr/000-template.md +17 -0
- data/docs/adr/001-terminal-core-boundary.md +26 -0
- data/docs/adr/README.md +3 -0
- data/lib/tarazed/cell.rb +5 -0
- data/lib/tarazed/grid.rb +379 -0
- data/lib/tarazed/pty.rb +204 -0
- data/lib/tarazed/scrollback.rb +29 -0
- data/lib/tarazed/version.rb +5 -0
- data/lib/tarazed/vt.rb +444 -0
- data/lib/tarazed/windows/conpty.rb +255 -0
- data/lib/tarazed.rb +12 -0
- data/sig/tarazed.rbs +139 -0
- metadata +86 -0
data/lib/tarazed/pty.rb
ADDED
|
@@ -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
|
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
|