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.
- checksums.yaml +7 -0
- data/README.md +144 -0
- data/bin/record +3 -0
- data/bin/rubyterm +11 -0
- data/example-config.toml +61 -0
- data/lib/ansibackend.rb +155 -0
- data/lib/bitmapwindow.rb +176 -0
- data/lib/charsets.rb +52 -0
- data/lib/controller.rb +80 -0
- data/lib/escapeparser.rb +71 -0
- data/lib/keymap.rb +112 -0
- data/lib/palette.rb +14 -0
- data/lib/rubyterm/app.rb +580 -0
- data/lib/rubyterm/version.rb +5 -0
- data/lib/rubyterm.rb +47 -0
- data/lib/term.rb +657 -0
- data/lib/termbuffer.rb +365 -0
- data/lib/trackchanges.rb +319 -0
- data/lib/utf8decoder.rb +77 -0
- data/lib/window.rb +410 -0
- data/lib/windowadapter.rb +161 -0
- metadata +127 -0
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
|
+
|