rubyterm 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.
data/lib/term.rb ADDED
@@ -0,0 +1,657 @@
1
+ require_relative 'palette' # PALETTE_BASIC, FG, BG
2
+ require_relative 'escapeparser'
3
+ require_relative 'utf8decoder'
4
+ require_relative 'charsets'
5
+
6
+ # The escape/control interpreter: it turns a byte stream into operations on
7
+ # a buffer, and knows *nothing* about X11, windows, or how its buffer is
8
+ # rendered. It talks only to its buffer (a TrackChanges, which owns the
9
+ # render backend). The same interpreter therefore drives a terminal that
10
+ # renders to an X11 window or to a terminal (AnsiBackend), or for a
11
+ # multiplexer / TUI library, depending only on the backend behind the
12
+ # buffer. It carries no pixel/colour constants and no rendering: even the
13
+ # cursor is just a position it reports (#draw_cursor) for the buffer to
14
+ # render as an overlay.
15
+ class Term
16
+ attr_accessor :x, :y, :wraparound, :cursor, :origin_mode,
17
+ :mouse_mode, :mouse_reporting, :tabs, :esc, :mode, :mouse_buttons
18
+
19
+ # Object receiving terminal query replies (DSR/DA). Must respond to
20
+ # #report_position(x,y) and the three Device Attributes replies
21
+ # #device_attr_primary / #device_attr_secondary / #device_attr_tertiary.
22
+ # In the live terminal this is the Controller (which writes to the
23
+ # pty); in the test harness it is a Session capturing responses.
24
+ attr_accessor :responder
25
+
26
+ def initialize(buffer)
27
+ @buffer = buffer
28
+
29
+ # FIXME: I should consider whether to change origin to match the terminal handling
30
+ # as it might be easier.
31
+ @x = 0; @y = 0
32
+
33
+ # Initial size only; the host resizes to the real geometry before use.
34
+ @term_width = 80
35
+ @term_height = 24
36
+
37
+ @tabs = 40.times.map {|i| i * 8}
38
+
39
+ # EscapeParser instance when an escape code is being parsed.
40
+ # FIXME: Clearing this each time is probably slow.
41
+ @esc = nil
42
+
43
+
44
+ @bg = BG
45
+ @fg = FG
46
+ @mode = 0
47
+
48
+ @wraparound = true
49
+
50
+ # Show cursor?
51
+ @cursor = true
52
+
53
+ # DECOM - Origin Mode - https://vt100.net/docs/vt510-rm/DECOM.html
54
+ # FIXME: Origin mode is currently only partially respected.
55
+ @origin_mode = false
56
+
57
+ # LNM - Line Feed / New Line Mode - See https://vt100.net/docs/vt100-ug/chapter3.html
58
+ # LNM (line feed/new line mode). When reset (the VT100 default), LF/VT/FF
59
+ # only move down; when set, they also return to column 0. The pty's
60
+ # ONLCR already turns a program's "\n" into "\r\n", so off is both
61
+ # correct and what tmux/xterm do.
62
+ @lnm = false
63
+ # IRM (insert/replace mode). When set, printed characters are inserted
64
+ # at the cursor, shifting the rest of the line right, rather than
65
+ # overwriting. Default replace (off).
66
+ @irm = false
67
+
68
+ # :x10, :v200, :v200_highlight, :btn_event, :any_event
69
+ # FIXME: Only :btn_event_mouse supported so far
70
+ # See: https://www.xfree86.org/current/ctlseqs.html ("Mouse tracking")
71
+ @mouse_mode = nil
72
+ # Mouse reporting format. nil == x10, :multibyte (NOT SUPPORTED), :digits,
73
+ # :urxvt (NOT SUPPORTED, probably never)
74
+ @mouse_reporting = nil
75
+ # Currently pressed mouse buttons.
76
+ @mouse_buttons = nil
77
+
78
+ # Character set state (GL/GR designation per vt100/vt220).
79
+ @gl = 0
80
+ @gr = nil # FIXME: GR shifting not implemented
81
+ @g = [DefaultCharset, nil, nil, nil]
82
+ @saved = nil # Saved cursor state (ESC 7 / ESC 8)
83
+
84
+ @decoder = UTF8Decoder.new
85
+ @responder = nil
86
+ end
87
+
88
+ def charset = @g[@gl] || DefaultCharset
89
+
90
+ def width = @term_width
91
+ def height = @term_height
92
+
93
+ def clear_to_end = @buffer.clear_line(@y, @x)
94
+ def clear_to_start = @buffer.clear_line(@y, 0, @x)
95
+ def clear_line(y=nil) = @buffer.clear_line(y||@y, 0)
96
+ def clear_above
97
+ (0...y).each {|y| clear_line(y) }
98
+ clear_to_start
99
+ end
100
+
101
+ def clear_below
102
+ clear_to_end
103
+ (y+1..height).each {|y| clear_line(y)}
104
+ end
105
+
106
+ # Per vt100 user guide:
107
+ # Erase all of the display –
108
+ # all lines are erased, changed to single-width,
109
+ # and the cursor does not move.
110
+ #
111
+ def clear_screen
112
+ @buffer.scroll_start = nil
113
+ @buffer.scroll_end = nil
114
+ @buffer.clear # the buffer (TrackChanges) also clears the backend
115
+ end
116
+
117
+ # RIS - Reset to Initial State (ESC c). Full reset: restore margins,
118
+ # modes, charsets, tab stops and attributes to their defaults, home the
119
+ # cursor and clear the screen.
120
+ def reset
121
+ @x = @y = 0
122
+ @tabs = 40.times.map {|i| i * 8}
123
+ @fg = FG; @bg = BG; @mode = 0
124
+ invalidate_colours
125
+ @wraparound = true
126
+ @cursor = true
127
+ @origin_mode = false
128
+ @lnm = false
129
+ @irm = false
130
+ @mouse_mode = nil
131
+ @mouse_reporting = nil
132
+ @gl = 0; @gr = nil
133
+ @g = [DefaultCharset, nil, nil, nil]
134
+ @saved = nil
135
+ clear_screen # also resets the scroll region and clears
136
+ end
137
+
138
+ # ED - Erase In Display - ESC [ Ps J
139
+ def erase_in_display(ps = 0)
140
+ case ps.to_i
141
+ when 0 then clear_below
142
+ when 1 then clear_above
143
+ when 2 then clear_screen
144
+ when 3 then @buffer.clear # FIXME: Not in VT100. Where is this from?
145
+ end
146
+ end
147
+
148
+ # EL - Erase In Line - ESC [ Ps K
149
+ def erase_in_line(ps = 0)
150
+ case ps.to_i
151
+ when 0 then clear_to_end
152
+ when 1 then clear_to_start
153
+ when 2 then clear_line
154
+ else
155
+ unhandled(:erase_in_line, ps)
156
+ end
157
+ end
158
+
159
+ def insert_lines(num) = @buffer.insert_lines(@y, num||1, height)
160
+
161
+ def delete
162
+ @x = clampw(@x - 1)
163
+ @buffer.set(@x,@y, 66, fg, bg, @mode)
164
+ end
165
+
166
+ # FIXME (still broken) Emacs uses this to scroll up
167
+ def delete_lines(num) = @buffer.delete_lines(@y, num, height)
168
+
169
+ # DCH - delete characters at the cursor, shifting the line left.
170
+ def delete_chars(num)
171
+ @buffer.delete_chars(@x, @y, num || 1)
172
+ redraw_line_from_cursor
173
+ end
174
+
175
+ # ESC [ Pn A / B
176
+ # FIXME: Should these not use clamph?
177
+ def cursor_up(lines) = (@y = clamph(@y - lines.to_i.clamp(1,height)))
178
+ def cursor_down(lines) = (@y = clamph(@y + lines.to_i.clamp(1,height)))
179
+
180
+ def decaln
181
+ # DEC alignment; only purpose served here is for vttest
182
+ # so doesn't need to be efficient
183
+ @buffer.scroll_start = nil
184
+ @buffer.scroll_end = nil
185
+ width.times.each do |x|
186
+ height.times.each do |y|
187
+ @buffer.set(x,y,'E',fg,bg,0)
188
+ end
189
+ end
190
+ @buffer.draw_flush
191
+ end
192
+
193
+ def resize(w,h)
194
+ @term_width = w
195
+ @term_height = h
196
+ end
197
+
198
+ def scroll_up(num=1)
199
+ # The buffer (TrackChanges) drives the backend scroll - the blit and the
200
+ # scrolled-back-view handling are rendering concerns, not interpreter
201
+ # ones.
202
+ num.times { @buffer.scroll_up }
203
+ end
204
+
205
+ def scroll_if_needed
206
+ # Scroll at the scroll-region bottom margin, which (like IND/RI)
207
+ # applies regardless of origin mode - LF and wrap both scroll the
208
+ # region, not the whole screen, when a region is set.
209
+ dy = @y - region_bottom
210
+ if dy > 0
211
+ scroll_up(dy)
212
+ @y -= dy
213
+ end
214
+ end
215
+
216
+ def parse_color(codes)
217
+ case c = codes.shift
218
+ when 5; PALETTE256[codes.shift]
219
+ when 2; codes.shift << 16 | codes.shift << 8 | codes.shift
220
+ else; BG
221
+ end
222
+ end
223
+
224
+
225
+ # Resolved fg/bg are recomputed only when an SGR (or reset) changes
226
+ # @fg/@bg/@mode - not per character. putchar resolves the colour for every
227
+ # printable glyph, so doing the String/palette dance each time showed up
228
+ # in the profile; memoise and invalidate via #invalidate_colours.
229
+ def fg
230
+ @fg_resolved ||=
231
+ @fg.is_a?(String) ? PALETTE_BASIC[@fg.to_i + (@mode.allbits?(BOLD) ? 8:0)] : @fg
232
+ end
233
+ def bg
234
+ @bg_resolved ||= @bg.is_a?(String) ? PALETTE_BASIC[@bg.to_i] : @bg
235
+ end
236
+ def invalidate_colours; @fg_resolved = @bg_resolved = nil; end
237
+
238
+ def set_modes(codes)
239
+ while c = codes.shift
240
+ case c
241
+ when 0; @mode = 0; @fg = FG; @bg = BG
242
+ when 1; @mode |= BOLD
243
+ when 2; @mode |= FAINT
244
+ when 3; @mode |= ITALICS # FIXME
245
+ when 4; @mode |= UNDERLINE
246
+ when 5; @mode |= BLINK
247
+ when 6; @mode |= RAPID_BLINK
248
+ when 7; @mode |= INVERSE
249
+ when 8; @mode |= INVISIBLE
250
+ when 9; @mode |= CROSSED_OUT
251
+ when 21; @mode |= DBL_UNDERLINE
252
+ when 22; @mode &= ~BOLD & ~FAINT
253
+ when 23; @mode &= ~ITALICS
254
+ when 24; @mode &= ~UNDERLINE & ~DBL_UNDERLINE
255
+ when 25; @mode &= ~BLINK & ~RAPID_BLINK
256
+ when 27; @mode &= ~INVERSE
257
+ when 28; @mode &= ~INVISIBLE
258
+ when 29; @mode &= ~CROSSED_OUT
259
+ when 30..37; @fg = (c-30).to_s # FIXME: Hack
260
+ when 38; @fg = parse_color(codes)
261
+ when 39; @fg = FG
262
+ when 40..47; @bg = (c-40).to_s # FIXME: Hack
263
+ when 48; @bg = parse_color(codes)
264
+ when 49; @bg = BG
265
+ when 53; @mode |= OVERLINE
266
+ when 55; @mode &= ~OVERLINE
267
+ else return unhandled(:sgr, c)
268
+ end
269
+ end
270
+ ensure
271
+ invalidate_colours
272
+ end
273
+
274
+
275
+ def wrap_if_needed
276
+ if @x >= line_width
277
+ if @wraparound
278
+ @x = 0
279
+ @y += 1
280
+ else
281
+ @x = line_width-1
282
+ end
283
+ elsif @x < 0
284
+ if @wraparound
285
+ @y -= 1
286
+ @x = line_width-1
287
+ else
288
+ @x = 0
289
+ end
290
+ end
291
+ end
292
+
293
+ # The cursor is a render-time overlay, not interpreter state: the
294
+ # interpreter just reports where the cursor is (and whether it's shown);
295
+ # the buffer/backend renders it. (See docs/architecture-review.md Phase 4.)
296
+ def clear_cursor = @buffer.clear_cursor
297
+
298
+ def draw_cursor
299
+ x, y = @x, @y
300
+ y += 1 if x >= width # pending-wrap: the cursor shows on the next row
301
+ @buffer.draw_cursor(x, y, @cursor)
302
+ end
303
+
304
+ # FIXME: Redrawing full spans would be better.
305
+ def redraw_line_from_cursor
306
+ clear_cursor
307
+ (@x..width).each {|x| @buffer.redraw(x,@y) }
308
+ draw_cursor
309
+ end
310
+
311
+ # Set a line's width/height attribute (DECDHL/DECDWL/DECSWL) and re-render
312
+ # the whole line: a single/double switch changes the size of glyphs
313
+ # already on the line, so the cells written before the attribute must be
314
+ # repainted (vttest sets the attribute *after* writing the text).
315
+ def set_line_attrs(attr)
316
+ @buffer.set_lineattrs(@y, attr)
317
+ clear_cursor
318
+ # Repaint this line and its neighbours from their own attributes. A
319
+ # (previous) double-height attribute on this line draws into the row
320
+ # above/below; switching to a shorter attribute must clear those
321
+ # spilled halves, which only repainting the neighbour rows does.
322
+ [@y - 1, @y, @y + 1].each do |row|
323
+ next if row < 0 || row >= height
324
+ (0...width).each {|x| @buffer.redraw(x, row) }
325
+ end
326
+ @buffer.draw_flush
327
+ draw_cursor
328
+ end
329
+
330
+ def set_width_and_clear(w)
331
+ # DECCOLM. Set the logical width, then let the display layer actually
332
+ # realise the column change - by rescaling the font or resizing the
333
+ # window (RubyTerm#set_columns); a no-op in the headless harness.
334
+ resize(w, height)
335
+ @buffer.set_columns(w)
336
+ clear_screen
337
+ end
338
+
339
+ def origin = @origin_mode ? (@buffer.scroll_start || 0) : 0
340
+ # Inclusive bottom row of the active region. CSI scroll_end is already
341
+ # stored as an inclusive index; when unset the active region is the
342
+ # whole screen, so the last valid row is height - 1.
343
+ def bottom = @origin_mode ? (@buffer.scroll_end || height - 1) : height - 1
344
+ # The scroll region margins. Unlike #origin/#bottom these are NOT gated
345
+ # on origin mode: DECSTBM governs IND/RI/LF scrolling whether or not
346
+ # DECOM is set (origin mode only affects cursor addressing).
347
+ def region_top = @buffer.scroll_start || 0
348
+ def region_bottom = @buffer.scroll_end || height - 1
349
+ # Usable column count of the current line. Double-width/height lines show
350
+ # each cell twice as wide, so only width/2 columns fit; the cursor's last
351
+ # valid column is therefore line_width-1.
352
+ def line_width
353
+ case @buffer.lineattrs(@y)
354
+ when :dbl_upper, :dbl_lower, :dbl_single then width / 2
355
+ else width
356
+ end
357
+ end
358
+ def clampw(i) = i.clamp(0,line_width-1)
359
+ def clamph(i) = i.clamp(origin,bottom)
360
+
361
+ # IND - index: down one line; at the bottom margin scroll the region up.
362
+ def index
363
+ if @y >= region_bottom
364
+ @y = region_bottom
365
+ scroll_up(1)
366
+ else
367
+ @y += 1
368
+ end
369
+ end
370
+
371
+ # RI - reverse index: up one line; at the top margin scroll the region
372
+ # down (insert a blank line at the top, discarding the region's last).
373
+ def reverse_index
374
+ if @y <= region_top
375
+ @y = region_top
376
+ insert_lines(1)
377
+ else
378
+ @y -= 1
379
+ end
380
+ end
381
+
382
+ def linefeed
383
+ @x = 0 if @lnm
384
+ @buffer.draw_flush
385
+ @y = clamph(@y) + 1
386
+ scroll_if_needed
387
+ end
388
+
389
+ def handle_dec(s) # CSI '?' -> DEC private modes
390
+ args = s[2..-2].split(/[:;]/).map{|i| i.empty? ? nil : i.to_i }
391
+ case s[-1]
392
+ when "h","l"
393
+ set = s[-1] == "h"
394
+ args.each do |code|
395
+ case code
396
+ when 3 then set_width_and_clear(set ? 132 : 80)
397
+ when 6 then @origin_mode = set
398
+ when 7 then @wraparound = set
399
+ when 9
400
+ # FIXME: Unsupported X10 mouse reporting mode
401
+ when 20 then @lnm = set
402
+ when 25
403
+ @cursor = set
404
+ clear_cursor if !set
405
+ when 47;
406
+ # Start/end alternate screen mode
407
+ # FIXME: Save/restore
408
+ # FIXME: Scrollback should be disabled/enabled.
409
+ clear_screen
410
+
411
+ # Extended mouse modes
412
+ # See https://terminalguide.namepad.de/mouse/
413
+ when 1000 then @mouse_mode = set ? :vt200 : nil
414
+ when 1001 then @mouse_mode = set ? :vt200_highlight : nil
415
+ when 1002 then @mouse_mode = set ? :btn_event : nil
416
+ when 1003 then @mouse_mode = set ? :any_event : nil
417
+ when 1006 then @mouse_reporting = set ? :digits : nil
418
+ when 2004
419
+ # FIXME: Bracketed paste.
420
+ end
421
+ end
422
+ end
423
+ end
424
+
425
+ CSI_MAP = {
426
+ "J" => :erase_in_display,
427
+ "K" => :erase_in_line
428
+ }
429
+
430
+ def handle_csi(s)
431
+ return handle_dec(s) if s[1] == "?"
432
+ args = s[1..-2].split(/[:;]/).map{|i| i.empty? ? nil : i.to_i }
433
+
434
+
435
+ cmd = s[-1]
436
+ if CSI_MAP[cmd]
437
+ return send(CSI_MAP[cmd], *args)
438
+ end
439
+
440
+ case s[-1]
441
+ when "@";
442
+ @buffer.insert(@x,@y,args[0] || 1,[32,0,0,0])
443
+ redraw_line_from_cursor
444
+ when "A" then cursor_up(args[0])
445
+ when "B" then cursor_down(args[0])
446
+ when "C"
447
+ @x = clampw(@x + args[0].to_i.clamp(1,width))
448
+ when "D"
449
+ @x = clampw(@x - args[0].to_i.clamp(1,width))
450
+ when "G"
451
+ @x = clampw((args[0]||1)-1)
452
+ when "H", "f"
453
+ @y = (origin + args[0].to_i.clamp(1,99999))-1
454
+ @x = (args[1]||1)-1
455
+ #when "J" then erase_in_display(args[0])
456
+ #when "K" then erase_in_line(args[0])
457
+ when "L" then insert_lines(args[0])
458
+ when "M" then delete_lines(args[0]||1)
459
+ when "P" then delete_chars(args[0]||1)
460
+ when "S" then scroll_up(args[0]||1)
461
+ when "T"; # Scroll down
462
+ when "c"
463
+ # Device Attributes. The reply type depends on the private prefix;
464
+ # answering DA1/DA2 with the wrong kind (e.g. a DA3 DCS) means a
465
+ # host like tmux fails to recognise it and leaks it to the pane.
466
+ # CSI c / CSI 0 c -> primary (DA1), reply CSI ? ... c
467
+ # CSI > c / CSI > 0 c-> secondary (DA2), reply CSI > ... c
468
+ # CSI = c -> tertiary (DA3), reply DCS ! | ... ST
469
+ if block_given?
470
+ case s[1]
471
+ when ">" then yield(:device_attr_secondary)
472
+ when "=" then yield(:device_attr_tertiary)
473
+ else yield(:device_attr_primary)
474
+ end
475
+ end
476
+ when "d"
477
+ @y = clamph(origin+(args[0]||1) - 1) # FIXME: Should these be clamped
478
+ when "g"
479
+ case args[0].to_i
480
+ when 0 then @tabs.delete(@x)
481
+ when 3 then @tabs = []
482
+ end
483
+ when "h", "l"
484
+ # Standard (non-DEC-private) modes - SM/RM. 4 = IRM, 20 = LNM.
485
+ set = s[-1] == "h"
486
+ args.each do |code|
487
+ case code
488
+ when 4 then @irm = set
489
+ when 20 then @lnm = set
490
+ end
491
+ end
492
+ when "m"; set_modes(args.empty? ? [0] : args)
493
+ when "n"
494
+ case args[0]
495
+ when 6
496
+ yield(:report_position) if block_given?
497
+ else
498
+ unhandled(:dsr, args)
499
+ end
500
+ when "r"
501
+ @buffer.scroll_start = (args[0] || 1)-1
502
+ @buffer.scroll_end = (args[1] || height)-1
503
+ # DECSTBM homes the cursor: to the origin (region top) in origin
504
+ # mode, otherwise to screen home.
505
+ @x = 0
506
+ @y = origin
507
+ else
508
+ unhandled(:csi, s)
509
+ end
510
+ nil
511
+ end
512
+
513
+
514
+ # This is the *preferred* public interface:
515
+ #
516
+ # Feed raw bytes (as read from the pty) into the terminal. Handles
517
+ # UTF-8 decoding, control characters and escape sequences. This is
518
+ # synchronous: when it returns, all bytes have been interpreted and
519
+ # the buffer updated (rendering may still be batched in TrackChanges
520
+ # until #draw_flush is called on the buffer).
521
+ def feed(str)
522
+ @decoder << str
523
+ # each_codepoint yields Integer codepoints directly (the decoder already
524
+ # maps bytes it can't decode to U+FFFD), so the hot path allocates no
525
+ # per-character String and does no per-character valid_encoding?/ord.
526
+ @decoder.each_codepoint { |cp| putchar(cp) }
527
+ rescue StandardError
528
+ # Last-resort guard so a malformed sequence can't take down the input
529
+ # thread; stays silent (no debug output to the pane/stderr).
530
+ end
531
+ alias write feed
532
+
533
+ def putchar(ch)
534
+ if ch.is_a?(String)
535
+ STDERR.puts "WARNING: Should be int"
536
+ ch = ch.ord
537
+ end
538
+ if @esc&.put(ch)
539
+ handle_escape(ch)
540
+ elsif ch < 32
541
+ handle_control(ch)
542
+ else
543
+ wrap_if_needed
544
+ scroll_if_needed
545
+ return if ch == 127 # DEL is ignored in the data stream
546
+
547
+ # IRM (insert mode): shift the rest of the line right and repaint it,
548
+ # then drop the new glyph into the gap.
549
+ if @irm
550
+ @buffer.insert(@x, @y, 1, [32,0,0,0])
551
+ @buffer.set(@x, @y, charset[ch], fg, bg, @mode)
552
+ redraw_line_from_cursor
553
+ else
554
+ @buffer.set(@x, @y, charset[ch], fg, bg, @mode)
555
+ end
556
+ @y = clamph(@y)
557
+ @x += 1
558
+ scroll_if_needed
559
+ end
560
+ end
561
+
562
+ def handle_escape(ch)
563
+ return false if !@esc.complete?
564
+ s = @esc.str
565
+ if s[0] == ?[
566
+ handle_csi(s) {|op| respond(op) }
567
+ @esc = nil
568
+ return
569
+ end
570
+
571
+ case s
572
+ when "D"; index # IND
573
+ when "E"; index; @x = 0 # NEL
574
+ when "H"; @tabs = (@tabs << @x).sort.uniq
575
+ when "M"; reverse_index # RI
576
+ when "#3"; set_line_attrs(:dbl_upper) # DECDHL top half
577
+ when "#4"; set_line_attrs(:dbl_lower) # DECDHL bottom half
578
+ when "#5"; set_line_attrs(0) # DECSWL single width
579
+ when "#6"; set_line_attrs(:dbl_single) # DECDWL double width
580
+ when "c"; reset # RIS
581
+ when "#8"; decaln
582
+ when "(B"; @g[0] = DefaultCharset
583
+ when ")B"; @g[1] = DefaultCharset
584
+ when "(0"; @g[0] = GraphicsCharset
585
+ when ")0"; @g[1] = GraphicsCharset
586
+ when "7"; @saved = [@x,@y,@gl,@gr,@g.dup]
587
+ when "8"
588
+ # DECRC with no prior DECSC: default to home position and default
589
+ # charsets rather than leaving @x/@y nil (which crashes draw_cursor).
590
+ sx, sy, sgl, sgr, sg = @saved || [0, 0, 0, nil, [DefaultCharset, nil, nil, nil]]
591
+ @x, @y, @gl, @gr, @g = sx, sy, sgl, sgr, sg
592
+ else
593
+ unhandled(:escape, s)
594
+ end
595
+
596
+ @esc = nil
597
+ end
598
+
599
+ def handle_control(ch)
600
+ case ch
601
+ when 1,2;
602
+ when 7; unhandled(:bell)
603
+ when 8;
604
+ # Backspace. From the pending-wrap state (cursor parked past the last
605
+ # column after printing there) BS clears the pending wrap and stays on
606
+ # the last column, rather than stepping back two.
607
+ if @x >= line_width then @x = line_width - 1
608
+ elsif @x > 0 then @x -= 1
609
+ end
610
+ when 9
611
+ if i = @tabs.index {|t| t > @x}
612
+ # FIXME: This is only right behaviour if wrap is off, is it not?
613
+ @x = clampw(@tabs[i])
614
+ else
615
+ # No tab stop to the right (e.g. all stops cleared via TBC): HT
616
+ # advances to the last column, per VT100.
617
+ @x = line_width - 1
618
+ end
619
+ when 10, 11
620
+ linefeed
621
+ when 12; @x = 0; @y = 0
622
+ when 13; @x = 0
623
+ when 14; @gl = 1
624
+ when 15; @gl = 0
625
+ when 16..26;
626
+ when 27; @esc = EscapeParser.new # FIXME: Is this right if !@esc.nil? ?
627
+ when 28..31;
628
+ end
629
+ end
630
+
631
+ private
632
+
633
+ # Observability seam for input the terminal does not (yet) implement:
634
+ # unknown escape/control sequences, SGR parameters, query types. A no-op
635
+ # in production - it is deliberately SILENT (programs constantly emit
636
+ # things we don't handle, e.g. OSC title sets and bright-colour SGR;
637
+ # printing them spewed debug noise to stderr). The harness overrides this
638
+ # to collect what real programs use that we don't support yet.
639
+ def unhandled(kind, detail = nil)
640
+ end
641
+
642
+ # Dispatch a terminal query reply request (yielded by handle_csi)
643
+ # to the responder, if one is attached.
644
+ def respond(op)
645
+ return if !@responder
646
+ case op
647
+ when :report_position then @responder.report_position(@x, @y)
648
+ when :device_attr_primary then @responder.device_attr_primary
649
+ when :device_attr_secondary then @responder.device_attr_secondary
650
+ when :device_attr_tertiary then @responder.device_attr_tertiary
651
+ else
652
+ unhandled(:respond, op)
653
+ end
654
+ end
655
+
656
+ end
657
+