tty-command-window 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 +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/lib/tty/command/window/ansi.rb +112 -0
- data/lib/tty/command/window/block.rb +184 -0
- data/lib/tty/command/window/child_session.rb +161 -0
- data/lib/tty/command/window/coordinator.rb +315 -0
- data/lib/tty/command/window/emulator.rb +613 -0
- data/lib/tty/command/window/input_router.rb +249 -0
- data/lib/tty/command/window/integration.rb +195 -0
- data/lib/tty/command/window/runner.rb +245 -0
- data/lib/tty/command/window/spinner.rb +30 -0
- data/lib/tty/command/window/trap_manager.rb +117 -0
- data/lib/tty/command/window/version.rb +9 -0
- data/lib/tty/command/window/window_options.rb +76 -0
- data/lib/tty/command/window.rb +110 -0
- data/lib/tty-command-window.rb +3 -0
- metadata +116 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module TTY
|
|
6
|
+
class Command
|
|
7
|
+
module Window
|
|
8
|
+
# A VT100-subset terminal emulator with a fixed-size screen.
|
|
9
|
+
#
|
|
10
|
+
# The emulator maintains a +rows+ x +cols+ grid of cells (character +
|
|
11
|
+
# SGR attributes), interprets the escape sequences commonly emitted by
|
|
12
|
+
# CLI tools (cursor movement, erase, scroll regions, insert/delete,
|
|
13
|
+
# colors, alternate screen) and keeps a plain-text scrollback of lines
|
|
14
|
+
# that scroll off the top of the main screen.
|
|
15
|
+
#
|
|
16
|
+
# It is not thread-safe on its own; callers synchronize access.
|
|
17
|
+
class Emulator
|
|
18
|
+
BLANK = [" ", nil].freeze
|
|
19
|
+
WIDE_PLACEHOLDER = ["", nil].freeze
|
|
20
|
+
MAX_SEQUENCE = 512
|
|
21
|
+
TAB_STOP = 8
|
|
22
|
+
|
|
23
|
+
# Pre-allocated frozen single-char strings for every printable ASCII
|
|
24
|
+
# codepoint. The ASCII fast path in +scan+ reuses these instead of
|
|
25
|
+
# allocating a new String per printable byte (see P0-3).
|
|
26
|
+
ASCII_CHARS = (0x20..0x7E).map { |b| b.chr(Encoding::UTF_8).freeze }.freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :rows, :cols, :cursor_row, :cursor_col, :scrollback
|
|
29
|
+
|
|
30
|
+
# @param rows [Integer] screen height in lines
|
|
31
|
+
# @param cols [Integer] screen width in columns
|
|
32
|
+
# @param scrollback_limit [Integer] max plain-text history lines kept
|
|
33
|
+
# @param responder [#call, nil] receives strings the terminal would
|
|
34
|
+
# send back to the child (cursor position reports etc.)
|
|
35
|
+
def initialize(rows:, cols:, scrollback_limit: DEFAULT_SCROLLBACK, responder: nil)
|
|
36
|
+
@rows = rows
|
|
37
|
+
@cols = [cols, 1].max
|
|
38
|
+
@scrollback_limit = scrollback_limit
|
|
39
|
+
@responder = responder
|
|
40
|
+
@scrollback = []
|
|
41
|
+
@partial_input = (+"").force_encoding(Encoding::BINARY)
|
|
42
|
+
full_reset
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Feed a chunk of raw child output into the emulator. Handles UTF-8
|
|
46
|
+
# characters and escape sequences split across chunk boundaries.
|
|
47
|
+
#
|
|
48
|
+
# @param data [String]
|
|
49
|
+
def feed(data)
|
|
50
|
+
buffer = @partial_input + data.dup.force_encoding(Encoding::BINARY)
|
|
51
|
+
@partial_input = (+"").force_encoding(Encoding::BINARY)
|
|
52
|
+
|
|
53
|
+
tail = incomplete_utf8_tail(buffer)
|
|
54
|
+
if tail.positive?
|
|
55
|
+
@partial_input = buffer.byteslice(-tail, tail)
|
|
56
|
+
buffer = buffer.byteslice(0, buffer.bytesize - tail)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
buffer.force_encoding(Encoding::UTF_8)
|
|
60
|
+
buffer = buffer.scrub("\u{FFFD}") unless buffer.valid_encoding?
|
|
61
|
+
scan(buffer)
|
|
62
|
+
invalidate_render_cache
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resize the screen width. Rows are fixed for the window's lifetime.
|
|
67
|
+
#
|
|
68
|
+
# @param cols [Integer]
|
|
69
|
+
def resize(cols:)
|
|
70
|
+
cols = [cols, 1].max
|
|
71
|
+
return if cols == @cols
|
|
72
|
+
|
|
73
|
+
@cols = cols
|
|
74
|
+
[@main_grid, @alt_grid].each do |grid|
|
|
75
|
+
grid.each { |row| resize_row(row, cols) }
|
|
76
|
+
end
|
|
77
|
+
@cursor_col = [@cursor_col, cols - 1].min
|
|
78
|
+
@pending_wrap = false
|
|
79
|
+
invalidate_render_cache
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] whether the child requested a visible cursor
|
|
83
|
+
def cursor_visible?
|
|
84
|
+
@cursor_visible
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Render one screen row as an ANSI string (no trailing newline, no
|
|
88
|
+
# clear-to-eol; trailing unstyled blanks are stripped).
|
|
89
|
+
#
|
|
90
|
+
# @param index [Integer]
|
|
91
|
+
# @return [String]
|
|
92
|
+
def render_line(index)
|
|
93
|
+
row = @grid[index] or return ""
|
|
94
|
+
last = row.rindex { |cell| cell[0] != " " || cell[1] } or return ""
|
|
95
|
+
|
|
96
|
+
out = +""
|
|
97
|
+
current = false
|
|
98
|
+
row[0..last].each do |(char, sgr)|
|
|
99
|
+
if sgr != current
|
|
100
|
+
out << (sgr ? "\e[0;#{sgr}m" : "\e[0m")
|
|
101
|
+
current = sgr
|
|
102
|
+
end
|
|
103
|
+
out << char
|
|
104
|
+
end
|
|
105
|
+
out << "\e[0m" if current
|
|
106
|
+
out
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Array<String>] every screen row rendered with ANSI colors
|
|
110
|
+
def render_lines
|
|
111
|
+
@render_lines ||= Array.new(@rows) { |i| render_line(i) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Array<String>] plain-text content of the visible screen
|
|
115
|
+
def screen_text
|
|
116
|
+
@grid.map { |row| plain_row(row) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [String] scrollback plus visible screen as plain text
|
|
120
|
+
def full_text
|
|
121
|
+
(@scrollback + screen_text).join("\n").rstrip
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def invalidate_render_cache
|
|
127
|
+
@render_lines = nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Scan the decoded UTF-8 buffer, taking an ASCII fast path when the
|
|
131
|
+
# parser is in ground state and the incoming bytes are printable
|
|
132
|
+
# ASCII (0x20-0x7E). Non-ASCII bytes and control/escape bytes fall
|
|
133
|
+
# through to the existing per-character state machine.
|
|
134
|
+
def scan(buffer)
|
|
135
|
+
bytes = buffer.b
|
|
136
|
+
len = bytes.bytesize
|
|
137
|
+
i = 0
|
|
138
|
+
while i < len
|
|
139
|
+
if @state == :ground
|
|
140
|
+
start = i
|
|
141
|
+
while i < len
|
|
142
|
+
b = bytes.getbyte(i)
|
|
143
|
+
break if b < 0x20 || b > 0x7E
|
|
144
|
+
|
|
145
|
+
i += 1
|
|
146
|
+
end
|
|
147
|
+
if i > start
|
|
148
|
+
put_ascii_run(bytes, start, i - start)
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
b = bytes.getbyte(i)
|
|
154
|
+
char_bytes = b < 0x80 ? 1 : expected_utf8_length(b)
|
|
155
|
+
char_bytes = len - i if i + char_bytes > len
|
|
156
|
+
char = bytes.byteslice(i, char_bytes).force_encoding(Encoding::UTF_8)
|
|
157
|
+
process(char)
|
|
158
|
+
i += char_bytes
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Write a run of printable ASCII bytes into the grid at the current
|
|
163
|
+
# cursor. Every byte is width-1, so the wide-cell logic in +put_char+
|
|
164
|
+
# can be skipped entirely; SGR attrs are hoisted out of the inner
|
|
165
|
+
# loop and printable-char strings come from a frozen table.
|
|
166
|
+
def put_ascii_run(bytes, offset, length)
|
|
167
|
+
attr = @sgr.to_params
|
|
168
|
+
j = 0
|
|
169
|
+
while j < length
|
|
170
|
+
wrap_if_pending
|
|
171
|
+
row = @grid[@cursor_row]
|
|
172
|
+
b = bytes.getbyte(offset + j)
|
|
173
|
+
char = ASCII_CHARS[b - 0x20]
|
|
174
|
+
cell = [char, attr]
|
|
175
|
+
row[@cursor_col] = cell
|
|
176
|
+
@last_cell = cell
|
|
177
|
+
if @cursor_col + 1 >= @cols
|
|
178
|
+
@cursor_col = @cols - 1
|
|
179
|
+
@pending_wrap = @autowrap
|
|
180
|
+
else
|
|
181
|
+
@cursor_col += 1
|
|
182
|
+
end
|
|
183
|
+
j += 1
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def full_reset
|
|
188
|
+
@main_grid = Array.new(@rows) { blank_row }
|
|
189
|
+
@alt_grid = Array.new(@rows) { blank_row }
|
|
190
|
+
@grid = @main_grid
|
|
191
|
+
@alt_screen = false
|
|
192
|
+
@cursor_row = 0
|
|
193
|
+
@cursor_col = 0
|
|
194
|
+
@saved_cursor = [0, 0]
|
|
195
|
+
@pending_wrap = false
|
|
196
|
+
@autowrap = true
|
|
197
|
+
@cursor_visible = true
|
|
198
|
+
@scroll_top = 0
|
|
199
|
+
@scroll_bottom = @rows - 1
|
|
200
|
+
@sgr = ANSI::SGRState.new
|
|
201
|
+
@state = :ground
|
|
202
|
+
@sequence = +""
|
|
203
|
+
@last_cell = nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def blank_row
|
|
207
|
+
Array.new(@cols) { BLANK }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def resize_row(row, cols)
|
|
211
|
+
if row.length > cols
|
|
212
|
+
row.slice!(cols..)
|
|
213
|
+
else
|
|
214
|
+
row.concat(Array.new(cols - row.length) { BLANK })
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def plain_row(row)
|
|
219
|
+
row.map { |cell| cell[0] }.join.rstrip
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Number of bytes at the end of +buffer+ forming an incomplete UTF-8
|
|
223
|
+
# character, or 0 when the buffer ends on a character boundary.
|
|
224
|
+
def incomplete_utf8_tail(buffer)
|
|
225
|
+
(1..3).each do |back|
|
|
226
|
+
break if back > buffer.bytesize
|
|
227
|
+
|
|
228
|
+
byte = buffer.getbyte(buffer.bytesize - back)
|
|
229
|
+
next if byte < 0x80 # ASCII: complete
|
|
230
|
+
next if byte >= 0xC0 && back >= expected_utf8_length(byte) # lead byte, sequence fits
|
|
231
|
+
return back if byte >= 0xC0 # lead byte, sequence cut short
|
|
232
|
+
|
|
233
|
+
# continuation byte: keep looking further back
|
|
234
|
+
end
|
|
235
|
+
0
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def expected_utf8_length(lead)
|
|
239
|
+
return 2 if lead < 0xE0
|
|
240
|
+
return 3 if lead < 0xF0
|
|
241
|
+
|
|
242
|
+
4
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def process(char)
|
|
246
|
+
case @state
|
|
247
|
+
when :ground then process_ground(char)
|
|
248
|
+
when :escape then process_escape(char)
|
|
249
|
+
when :csi then process_csi(char)
|
|
250
|
+
when :osc, :dcs then process_string_sequence(char)
|
|
251
|
+
when :charset then @state = :ground
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def process_ground(char)
|
|
256
|
+
case char
|
|
257
|
+
when "\e" then start_sequence(:escape)
|
|
258
|
+
when "\r"
|
|
259
|
+
@cursor_col = 0
|
|
260
|
+
@pending_wrap = false
|
|
261
|
+
when "\n", "\v", "\f" then index
|
|
262
|
+
when "\b"
|
|
263
|
+
@cursor_col = [@cursor_col - 1, 0].max
|
|
264
|
+
@pending_wrap = false
|
|
265
|
+
when "\t"
|
|
266
|
+
@cursor_col = [((@cursor_col / TAB_STOP) + 1) * TAB_STOP, @cols - 1].min
|
|
267
|
+
@pending_wrap = false
|
|
268
|
+
when "\a", "\x00", "\x0e", "\x0f"
|
|
269
|
+
# BEL / NUL / shift-in/out: ignore
|
|
270
|
+
else
|
|
271
|
+
put_char(char) if char >= " " || char.ord > 0x9f
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def start_sequence(state)
|
|
276
|
+
@state = state
|
|
277
|
+
@sequence = +""
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def process_escape(char)
|
|
281
|
+
case char
|
|
282
|
+
when "[" then start_sequence(:csi)
|
|
283
|
+
when "]" then start_sequence(:osc)
|
|
284
|
+
when "P", "^", "_" then start_sequence(:dcs)
|
|
285
|
+
when "(", ")", "*", "+" then @state = :charset
|
|
286
|
+
when "7"
|
|
287
|
+
save_cursor
|
|
288
|
+
@state = :ground
|
|
289
|
+
when "8"
|
|
290
|
+
restore_cursor
|
|
291
|
+
@state = :ground
|
|
292
|
+
when "D"
|
|
293
|
+
index
|
|
294
|
+
@state = :ground
|
|
295
|
+
when "M"
|
|
296
|
+
reverse_index
|
|
297
|
+
@state = :ground
|
|
298
|
+
when "E"
|
|
299
|
+
@cursor_col = 0
|
|
300
|
+
index
|
|
301
|
+
@state = :ground
|
|
302
|
+
when "c"
|
|
303
|
+
scrollback = @scrollback
|
|
304
|
+
full_reset
|
|
305
|
+
@scrollback = scrollback
|
|
306
|
+
@state = :ground
|
|
307
|
+
else
|
|
308
|
+
@state = :ground
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def process_csi(char)
|
|
313
|
+
if char.between?("@", "~")
|
|
314
|
+
dispatch_csi(char, @sequence)
|
|
315
|
+
@state = :ground
|
|
316
|
+
elsif @sequence.length > MAX_SEQUENCE
|
|
317
|
+
@state = :ground
|
|
318
|
+
else
|
|
319
|
+
@sequence << char
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def process_string_sequence(char)
|
|
324
|
+
if char == "\a" || (@sequence.end_with?("\e") && char == "\\")
|
|
325
|
+
@state = :ground
|
|
326
|
+
elsif char == "\e" || @sequence.length <= MAX_SEQUENCE
|
|
327
|
+
@sequence << char
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def dispatch_csi(final, sequence)
|
|
332
|
+
private_marker = sequence.start_with?("?")
|
|
333
|
+
params = sequence.delete("?<=>! ").split(/[;:]/).map { |p| p.empty? ? nil : p.to_i }
|
|
334
|
+
|
|
335
|
+
case final
|
|
336
|
+
when "A" then move_cursor(-count(params), 0)
|
|
337
|
+
when "B", "e" then move_cursor(count(params), 0)
|
|
338
|
+
when "C", "a" then move_cursor(0, count(params))
|
|
339
|
+
when "D" then move_cursor(0, -count(params))
|
|
340
|
+
when "E"
|
|
341
|
+
@cursor_col = 0
|
|
342
|
+
move_cursor(count(params), 0)
|
|
343
|
+
when "F"
|
|
344
|
+
@cursor_col = 0
|
|
345
|
+
move_cursor(-count(params), 0)
|
|
346
|
+
when "G", "`" then set_cursor(@cursor_row, count(params) - 1)
|
|
347
|
+
when "d" then set_cursor(count(params) - 1, @cursor_col)
|
|
348
|
+
when "H", "f" then set_cursor((params[0] || 1) - 1, (params[1] || 1) - 1)
|
|
349
|
+
when "J" then erase_display(params[0] || 0)
|
|
350
|
+
when "K" then erase_line(params[0] || 0)
|
|
351
|
+
when "L" then insert_lines(bounded_count(params, @scroll_bottom - @cursor_row + 1))
|
|
352
|
+
when "M" then delete_lines(bounded_count(params, @scroll_bottom - @cursor_row + 1))
|
|
353
|
+
when "P" then delete_chars(bounded_count(params, @cols - @cursor_col))
|
|
354
|
+
when "@" then insert_chars(bounded_count(params, @cols - @cursor_col))
|
|
355
|
+
when "X" then erase_chars(bounded_count(params, @cols - @cursor_col))
|
|
356
|
+
when "S" then scroll_up(bounded_count(params, @scroll_bottom - @scroll_top + 1))
|
|
357
|
+
when "T" then scroll_down(bounded_count(params, @scroll_bottom - @scroll_top + 1))
|
|
358
|
+
when "m" then @sgr.apply(params.map { |p| p || 0 })
|
|
359
|
+
when "r" then set_scroll_region(params[0] || 1, params[1] || @rows)
|
|
360
|
+
when "s" then save_cursor
|
|
361
|
+
when "u" then restore_cursor
|
|
362
|
+
when "h" then set_mode(params, private_marker, true)
|
|
363
|
+
when "l" then set_mode(params, private_marker, false)
|
|
364
|
+
when "n" then device_status_report(params[0] || 0)
|
|
365
|
+
when "c" then respond("\e[?6c")
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def count(params)
|
|
370
|
+
value = params[0] || 1
|
|
371
|
+
[value, 1].max
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Like +count+ but also clamped to a caller-supplied ceiling
|
|
375
|
+
# (typically screen geometry). Prevents adversarial CSI like
|
|
376
|
+
# +\e[999...9P+ from turning into an unbounded +amount.times+ loop
|
|
377
|
+
# inside +delete_chars+ / +scroll_up+ / friends (review P0-4, H6).
|
|
378
|
+
def bounded_count(params, ceiling)
|
|
379
|
+
n = count(params)
|
|
380
|
+
ceiling = 1 if ceiling < 1
|
|
381
|
+
[n, ceiling].min
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def respond(text)
|
|
385
|
+
@responder&.call(text)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# --- cursor -------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def move_cursor(d_row, d_col)
|
|
391
|
+
@cursor_row = (@cursor_row + d_row).clamp(0, @rows - 1)
|
|
392
|
+
@cursor_col = (@cursor_col + d_col).clamp(0, @cols - 1)
|
|
393
|
+
@pending_wrap = false
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def set_cursor(row, col)
|
|
397
|
+
@cursor_row = row.clamp(0, @rows - 1)
|
|
398
|
+
@cursor_col = col.clamp(0, @cols - 1)
|
|
399
|
+
@pending_wrap = false
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def save_cursor
|
|
403
|
+
@saved_cursor = [@cursor_row, @cursor_col]
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def restore_cursor
|
|
407
|
+
@cursor_row, @cursor_col = @saved_cursor
|
|
408
|
+
@cursor_row = @cursor_row.clamp(0, @rows - 1)
|
|
409
|
+
@cursor_col = @cursor_col.clamp(0, @cols - 1)
|
|
410
|
+
@pending_wrap = false
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def index
|
|
414
|
+
if @cursor_row == @scroll_bottom
|
|
415
|
+
scroll_up(1)
|
|
416
|
+
else
|
|
417
|
+
@cursor_row = [@cursor_row + 1, @rows - 1].min
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def reverse_index
|
|
422
|
+
if @cursor_row == @scroll_top
|
|
423
|
+
scroll_down(1)
|
|
424
|
+
else
|
|
425
|
+
@cursor_row -= 1
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# --- writing ------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def put_char(char)
|
|
432
|
+
width = Unicode::DisplayWidth.of(char)
|
|
433
|
+
|
|
434
|
+
if width.zero?
|
|
435
|
+
@last_cell[0] += char if @last_cell && !@last_cell.frozen?
|
|
436
|
+
return
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
wrap_if_pending
|
|
440
|
+
if width == 2 && @cursor_col >= @cols - 1
|
|
441
|
+
@grid[@cursor_row][@cursor_col] = BLANK
|
|
442
|
+
if @autowrap
|
|
443
|
+
@cursor_col = 0
|
|
444
|
+
index
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
row = @grid[@cursor_row]
|
|
449
|
+
cell = [char, @sgr.to_params]
|
|
450
|
+
row[@cursor_col] = cell
|
|
451
|
+
@last_cell = cell
|
|
452
|
+
row[@cursor_col + 1] = WIDE_PLACEHOLDER if width == 2 && @cursor_col + 1 < @cols
|
|
453
|
+
|
|
454
|
+
advance = width
|
|
455
|
+
if @cursor_col + advance >= @cols
|
|
456
|
+
@cursor_col = @cols - 1
|
|
457
|
+
@pending_wrap = @autowrap
|
|
458
|
+
else
|
|
459
|
+
@cursor_col += advance
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def wrap_if_pending
|
|
464
|
+
return unless @pending_wrap
|
|
465
|
+
|
|
466
|
+
@pending_wrap = false
|
|
467
|
+
@cursor_col = 0
|
|
468
|
+
index
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# --- scrolling ----------------------------------------------------
|
|
472
|
+
|
|
473
|
+
def scroll_up(amount)
|
|
474
|
+
amount.times do
|
|
475
|
+
removed = @grid.slice!(@scroll_top)
|
|
476
|
+
push_scrollback(removed) if !@alt_screen && @scroll_top.zero?
|
|
477
|
+
@grid.insert(@scroll_bottom, blank_row)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def scroll_down(amount)
|
|
482
|
+
amount.times do
|
|
483
|
+
@grid.slice!(@scroll_bottom)
|
|
484
|
+
@grid.insert(@scroll_top, blank_row)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def push_scrollback(row)
|
|
489
|
+
return if @scrollback_limit <= 0
|
|
490
|
+
|
|
491
|
+
@scrollback << plain_row(row)
|
|
492
|
+
@scrollback.shift(@scrollback.length - @scrollback_limit) if
|
|
493
|
+
@scrollback.length > @scrollback_limit
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def set_scroll_region(top, bottom)
|
|
497
|
+
top = top.clamp(1, @rows)
|
|
498
|
+
bottom = bottom.clamp(1, @rows)
|
|
499
|
+
return unless top < bottom
|
|
500
|
+
|
|
501
|
+
@scroll_top = top - 1
|
|
502
|
+
@scroll_bottom = bottom - 1
|
|
503
|
+
set_cursor(0, 0)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# --- erase / insert / delete ---------------------------------------
|
|
507
|
+
|
|
508
|
+
def erase_display(mode)
|
|
509
|
+
case mode
|
|
510
|
+
when 0
|
|
511
|
+
erase_line(0)
|
|
512
|
+
((@cursor_row + 1)...@rows).each { |i| @grid[i] = blank_row }
|
|
513
|
+
when 1
|
|
514
|
+
erase_line(1)
|
|
515
|
+
(0...@cursor_row).each { |i| @grid[i] = blank_row }
|
|
516
|
+
when 2, 3
|
|
517
|
+
(0...@rows).each { |i| @grid[i] = blank_row }
|
|
518
|
+
@scrollback.clear if mode == 3
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def erase_line(mode)
|
|
523
|
+
row = @grid[@cursor_row]
|
|
524
|
+
range =
|
|
525
|
+
case mode
|
|
526
|
+
when 0 then (@cursor_col...@cols)
|
|
527
|
+
when 1 then (0..@cursor_col)
|
|
528
|
+
else (0...@cols)
|
|
529
|
+
end
|
|
530
|
+
range.each { |i| row[i] = BLANK }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def insert_lines(amount)
|
|
534
|
+
return unless @cursor_row.between?(@scroll_top, @scroll_bottom)
|
|
535
|
+
|
|
536
|
+
amount.times do
|
|
537
|
+
@grid.slice!(@scroll_bottom)
|
|
538
|
+
@grid.insert(@cursor_row, blank_row)
|
|
539
|
+
end
|
|
540
|
+
@cursor_col = 0
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def delete_lines(amount)
|
|
544
|
+
return unless @cursor_row.between?(@scroll_top, @scroll_bottom)
|
|
545
|
+
|
|
546
|
+
amount.times do
|
|
547
|
+
@grid.slice!(@cursor_row)
|
|
548
|
+
@grid.insert(@scroll_bottom, blank_row)
|
|
549
|
+
end
|
|
550
|
+
@cursor_col = 0
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def delete_chars(amount)
|
|
554
|
+
row = @grid[@cursor_row]
|
|
555
|
+
amount.times do
|
|
556
|
+
row.delete_at(@cursor_col)
|
|
557
|
+
row << BLANK
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def insert_chars(amount)
|
|
562
|
+
row = @grid[@cursor_row]
|
|
563
|
+
amount.times do
|
|
564
|
+
row.insert(@cursor_col, BLANK)
|
|
565
|
+
row.pop
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def erase_chars(amount)
|
|
570
|
+
row = @grid[@cursor_row]
|
|
571
|
+
(@cursor_col...[@cursor_col + amount, @cols].min).each { |i| row[i] = BLANK }
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# --- modes ----------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
def set_mode(params, private_marker, enabled)
|
|
577
|
+
return unless private_marker
|
|
578
|
+
|
|
579
|
+
params.compact.each do |param|
|
|
580
|
+
case param
|
|
581
|
+
when 25 then @cursor_visible = enabled
|
|
582
|
+
when 7 then @autowrap = enabled
|
|
583
|
+
when 47, 1047, 1049 then switch_screen(enabled, save_state: param == 1049)
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def switch_screen(alt, save_state:)
|
|
589
|
+
return if alt == @alt_screen
|
|
590
|
+
|
|
591
|
+
@alt_screen = alt
|
|
592
|
+
if alt
|
|
593
|
+
save_cursor if save_state
|
|
594
|
+
@alt_grid = Array.new(@rows) { blank_row }
|
|
595
|
+
@grid = @alt_grid
|
|
596
|
+
set_cursor(0, 0)
|
|
597
|
+
else
|
|
598
|
+
@grid = @main_grid
|
|
599
|
+
restore_cursor if save_state
|
|
600
|
+
end
|
|
601
|
+
@pending_wrap = false
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def device_status_report(param)
|
|
605
|
+
case param
|
|
606
|
+
when 5 then respond("\e[0n")
|
|
607
|
+
when 6 then respond("\e[#{@cursor_row + 1};#{@cursor_col + 1}R")
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
end
|