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.
@@ -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