muxr 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 +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/bin/muxr +137 -0
- data/lib/muxr/application.rb +669 -0
- data/lib/muxr/client.rb +145 -0
- data/lib/muxr/command_dispatcher.rb +65 -0
- data/lib/muxr/drawer.rb +44 -0
- data/lib/muxr/input_handler.rb +218 -0
- data/lib/muxr/layout_manager.rb +91 -0
- data/lib/muxr/pane.rb +52 -0
- data/lib/muxr/protocol.rb +73 -0
- data/lib/muxr/pty_process.rb +92 -0
- data/lib/muxr/renderer.rb +468 -0
- data/lib/muxr/session.rb +87 -0
- data/lib/muxr/terminal.rb +817 -0
- data/lib/muxr/version.rb +3 -0
- data/lib/muxr/window.rb +110 -0
- data/lib/muxr.rb +18 -0
- data/muxr.gemspec +42 -0
- metadata +99 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# A minimal VT100/ANSI terminal emulator. It maintains a fixed grid of cells
|
|
3
|
+
# plus a cursor and parser state. Bytes fed from a PTY are interpreted into
|
|
4
|
+
# mutations of the grid which the Renderer then composites into the final
|
|
5
|
+
# frame. The emulator implements enough of the protocol to host typical
|
|
6
|
+
# interactive shells (bash, zsh) and line-oriented programs.
|
|
7
|
+
class Terminal
|
|
8
|
+
BOLD = 1
|
|
9
|
+
UNDERLINE = 2
|
|
10
|
+
REVERSE = 4
|
|
11
|
+
|
|
12
|
+
SCROLLBACK_MAX = 5000
|
|
13
|
+
|
|
14
|
+
Cell = Struct.new(:char, :fg, :bg, :attrs) do
|
|
15
|
+
def reset!
|
|
16
|
+
self.char = " "
|
|
17
|
+
self.fg = nil
|
|
18
|
+
self.bg = nil
|
|
19
|
+
self.attrs = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def copy_from(other)
|
|
23
|
+
self.char = other.char
|
|
24
|
+
self.fg = other.fg
|
|
25
|
+
self.bg = other.bg
|
|
26
|
+
self.attrs = other.attrs
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :rows, :cols, :cursor_row, :cursor_col, :view_offset
|
|
31
|
+
|
|
32
|
+
def initialize(rows: 24, cols: 80)
|
|
33
|
+
@rows = rows
|
|
34
|
+
@cols = cols
|
|
35
|
+
@buffer = Array.new(rows) { Array.new(cols) { blank_cell } }
|
|
36
|
+
@cursor_row = 0
|
|
37
|
+
@cursor_col = 0
|
|
38
|
+
@saved_cursor = [0, 0]
|
|
39
|
+
@fg = nil
|
|
40
|
+
@bg = nil
|
|
41
|
+
@attrs = 0
|
|
42
|
+
@autowrap_pending = false
|
|
43
|
+
@scroll_top = 0
|
|
44
|
+
@scroll_bottom = rows - 1
|
|
45
|
+
@parser_state = :ground
|
|
46
|
+
@parser_params = +""
|
|
47
|
+
@feed_remainder = +"".b
|
|
48
|
+
@dirty = true
|
|
49
|
+
@scrollback = []
|
|
50
|
+
@view_offset = 0
|
|
51
|
+
@selection_anchor = nil
|
|
52
|
+
@selection_cursor = nil
|
|
53
|
+
@selection_mode = :linear
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
attr_reader :selection_mode
|
|
57
|
+
|
|
58
|
+
def cell(r, c)
|
|
59
|
+
@buffer[r][c]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the Cell that should be visible at (r, c) given the current
|
|
63
|
+
# scrollback view_offset. When view_offset == 0 this is the live grid.
|
|
64
|
+
# When view_offset > 0, rows in the top of the visible area are sourced
|
|
65
|
+
# from @scrollback instead.
|
|
66
|
+
def visible_cell(r, c)
|
|
67
|
+
return @buffer[r][c] if @view_offset.zero?
|
|
68
|
+
idx = @scrollback.size - @view_offset + r
|
|
69
|
+
if idx < @scrollback.size
|
|
70
|
+
row = @scrollback[idx]
|
|
71
|
+
return blank_cell if row.nil? || c >= row.length
|
|
72
|
+
row[c]
|
|
73
|
+
else
|
|
74
|
+
@buffer[idx - @scrollback.size][c]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def scrollback_size
|
|
79
|
+
@scrollback.size
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def scrolled_back?
|
|
83
|
+
@view_offset > 0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def scroll_back(n = 1)
|
|
87
|
+
set_view_offset(@view_offset + n)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def scroll_forward(n = 1)
|
|
91
|
+
set_view_offset(@view_offset - n)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def scroll_to_top
|
|
95
|
+
set_view_offset(@scrollback.size)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def scroll_to_bottom
|
|
99
|
+
set_view_offset(0)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ---------- selection ----------
|
|
103
|
+
#
|
|
104
|
+
# Selection coordinates are in the combined "timeline":
|
|
105
|
+
# 0..scrollback.size-1 → @scrollback rows
|
|
106
|
+
# scrollback.size..scrollback.size+rows-1 → @buffer rows
|
|
107
|
+
# so the selection stays anchored to the same text as the user pages
|
|
108
|
+
# through history.
|
|
109
|
+
|
|
110
|
+
def selection_active?
|
|
111
|
+
!@selection_anchor.nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Place the moving cursor at a viewport position without dropping an
|
|
115
|
+
# anchor — the user is still navigating, not yet selecting.
|
|
116
|
+
def place_selection_cursor(r, c)
|
|
117
|
+
tr = timeline_row_for_visible(r).clamp(0, timeline_size - 1)
|
|
118
|
+
tc = c.clamp(0, @cols - 1)
|
|
119
|
+
@selection_cursor = [tr, tc]
|
|
120
|
+
@selection_anchor = nil
|
|
121
|
+
@dirty = true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Drop the anchor at the cursor's current position. `mode` controls the
|
|
125
|
+
# selection shape: :linear (character-by-character, reading order) or
|
|
126
|
+
# :block (rectangular).
|
|
127
|
+
def anchor_selection!(mode: :linear)
|
|
128
|
+
return unless @selection_cursor
|
|
129
|
+
@selection_anchor = @selection_cursor.dup
|
|
130
|
+
@selection_mode = mode
|
|
131
|
+
@dirty = true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Drop the anchor but keep the cursor so the user can continue navigating
|
|
135
|
+
# (vim's behavior when pressing v while already in linear visual mode).
|
|
136
|
+
def clear_anchor!
|
|
137
|
+
return unless @selection_anchor
|
|
138
|
+
@selection_anchor = nil
|
|
139
|
+
@dirty = true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Convenience for tests: place cursor at (r,c) AND anchor immediately.
|
|
143
|
+
def start_selection_at_visible(r, c, mode: :linear)
|
|
144
|
+
place_selection_cursor(r, c)
|
|
145
|
+
anchor_selection!(mode: mode)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def move_selection_cursor_by(dr, dc)
|
|
149
|
+
return unless @selection_cursor
|
|
150
|
+
tr, tc = @selection_cursor
|
|
151
|
+
ntr = (tr + dr).clamp(0, timeline_size - 1)
|
|
152
|
+
ntc = (tc + dc).clamp(0, @cols - 1)
|
|
153
|
+
return if ntr == tr && ntc == tc
|
|
154
|
+
@selection_cursor = [ntr, ntc]
|
|
155
|
+
ensure_selection_cursor_visible
|
|
156
|
+
@dirty = true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def selection_cursor_to(tr, tc)
|
|
160
|
+
return unless @selection_cursor
|
|
161
|
+
ntr = tr.clamp(0, timeline_size - 1)
|
|
162
|
+
ntc = tc.clamp(0, @cols - 1)
|
|
163
|
+
@selection_cursor = [ntr, ntc]
|
|
164
|
+
ensure_selection_cursor_visible
|
|
165
|
+
@dirty = true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def selection_cursor_to_line_start
|
|
169
|
+
return unless @selection_cursor
|
|
170
|
+
selection_cursor_to(@selection_cursor[0], 0)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def selection_cursor_to_line_end
|
|
174
|
+
return unless @selection_cursor
|
|
175
|
+
selection_cursor_to(@selection_cursor[0], @cols - 1)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def selection_cursor_to_top
|
|
179
|
+
selection_cursor_to(0, 0)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def selection_cursor_to_bottom
|
|
183
|
+
selection_cursor_to(timeline_size - 1, @cols - 1)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def clear_selection
|
|
187
|
+
return unless @selection_anchor
|
|
188
|
+
@selection_anchor = nil
|
|
189
|
+
@selection_cursor = nil
|
|
190
|
+
@dirty = true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def selected_at_visible?(r, c)
|
|
194
|
+
return false unless @selection_anchor
|
|
195
|
+
tr = timeline_row_for_visible(r)
|
|
196
|
+
inside_selection?(tr, c)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def selection_cursor_visible
|
|
200
|
+
return nil unless @selection_cursor
|
|
201
|
+
tr, tc = @selection_cursor
|
|
202
|
+
vr = tr - (@scrollback.size - @view_offset)
|
|
203
|
+
return nil unless vr.between?(0, @rows - 1)
|
|
204
|
+
[vr, tc]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def extract_selection_text
|
|
208
|
+
return "" unless @selection_anchor
|
|
209
|
+
if @selection_mode == :block
|
|
210
|
+
ar, ac = @selection_anchor
|
|
211
|
+
br, bc = @selection_cursor
|
|
212
|
+
min_r, max_r = ar <= br ? [ar, br] : [br, ar]
|
|
213
|
+
min_c, max_c = ac <= bc ? [ac, bc] : [bc, ac]
|
|
214
|
+
lines = []
|
|
215
|
+
(min_r..max_r).each do |tr|
|
|
216
|
+
row = timeline_row(tr)
|
|
217
|
+
if row.nil? || min_c >= row.length
|
|
218
|
+
lines << ""
|
|
219
|
+
next
|
|
220
|
+
end
|
|
221
|
+
last = [max_c, row.length - 1].min
|
|
222
|
+
chars = (min_c..last).map { |c| row[c]&.char || " " }
|
|
223
|
+
lines << chars.join.rstrip
|
|
224
|
+
end
|
|
225
|
+
return lines.join("\n")
|
|
226
|
+
end
|
|
227
|
+
sr, sc, er, ec = ordered_selection
|
|
228
|
+
lines = []
|
|
229
|
+
(sr..er).each do |tr|
|
|
230
|
+
row = timeline_row(tr)
|
|
231
|
+
if row.nil?
|
|
232
|
+
lines << ""
|
|
233
|
+
next
|
|
234
|
+
end
|
|
235
|
+
first = (tr == sr) ? sc : 0
|
|
236
|
+
last = (tr == er) ? ec : row.length - 1
|
|
237
|
+
last = [last, row.length - 1].min
|
|
238
|
+
chars = (first..last).map { |c| row[c]&.char || " " }
|
|
239
|
+
lines << chars.join.rstrip
|
|
240
|
+
end
|
|
241
|
+
lines.join("\n")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def dirty?
|
|
245
|
+
@dirty
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def clear_dirty!
|
|
249
|
+
@dirty = false
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def resize(rows, cols)
|
|
253
|
+
return if rows == @rows && cols == @cols
|
|
254
|
+
new_buf = Array.new(rows) { Array.new(cols) { blank_cell } }
|
|
255
|
+
keep_rows = [rows, @rows].min
|
|
256
|
+
keep_cols = [cols, @cols].min
|
|
257
|
+
src_start = @rows - keep_rows
|
|
258
|
+
keep_rows.times do |i|
|
|
259
|
+
keep_cols.times do |j|
|
|
260
|
+
new_buf[i][j].copy_from(@buffer[src_start + i][j])
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
@buffer = new_buf
|
|
264
|
+
@rows = rows
|
|
265
|
+
@cols = cols
|
|
266
|
+
@scroll_top = 0
|
|
267
|
+
@scroll_bottom = rows - 1
|
|
268
|
+
@cursor_row = @cursor_row.clamp(0, rows - 1)
|
|
269
|
+
@cursor_col = @cursor_col.clamp(0, cols - 1)
|
|
270
|
+
@autowrap_pending = false
|
|
271
|
+
# Selection points at timeline rows whose shape can't be remapped
|
|
272
|
+
# meaningfully through a resize, so drop it rather than show a smear.
|
|
273
|
+
@selection_anchor = nil
|
|
274
|
+
@selection_cursor = nil
|
|
275
|
+
@dirty = true
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def feed(data)
|
|
279
|
+
bytes = @feed_remainder + data.b
|
|
280
|
+
@feed_remainder = +"".b
|
|
281
|
+
str = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
282
|
+
unless str.valid_encoding?
|
|
283
|
+
# Find the longest valid UTF-8 prefix and stash the remainder for the
|
|
284
|
+
# next feed call so multi-byte characters don't get garbled across PTY
|
|
285
|
+
# read boundaries.
|
|
286
|
+
raw = bytes.bytes
|
|
287
|
+
while raw.any?
|
|
288
|
+
candidate = raw.pack("C*").force_encoding(Encoding::UTF_8)
|
|
289
|
+
break if candidate.valid_encoding?
|
|
290
|
+
@feed_remainder = ([raw.last] + @feed_remainder.bytes).pack("C*").b
|
|
291
|
+
raw.pop
|
|
292
|
+
end
|
|
293
|
+
str = raw.pack("C*").force_encoding(Encoding::UTF_8)
|
|
294
|
+
# Bail out completely if we couldn't decode anything yet.
|
|
295
|
+
return if str.empty?
|
|
296
|
+
end
|
|
297
|
+
str.each_char { |c| process_char(c) }
|
|
298
|
+
@dirty = true
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
def blank_cell
|
|
304
|
+
Cell.new(" ", nil, nil, 0)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def process_char(ch)
|
|
308
|
+
b = ch.ord
|
|
309
|
+
case @parser_state
|
|
310
|
+
when :ground
|
|
311
|
+
ground_char(ch, b)
|
|
312
|
+
when :escape
|
|
313
|
+
escape_char(ch, b)
|
|
314
|
+
when :csi
|
|
315
|
+
csi_char(ch, b)
|
|
316
|
+
when :osc
|
|
317
|
+
if b == 0x07 || b == 0x9c
|
|
318
|
+
@parser_state = :ground
|
|
319
|
+
elsif b == 0x1b
|
|
320
|
+
@parser_state = :osc_esc
|
|
321
|
+
end
|
|
322
|
+
when :osc_esc
|
|
323
|
+
@parser_state = :ground
|
|
324
|
+
when :charset
|
|
325
|
+
@parser_state = :ground
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def ground_char(ch, b)
|
|
330
|
+
case b
|
|
331
|
+
when 0x1b
|
|
332
|
+
@parser_state = :escape
|
|
333
|
+
when 0x07 # BEL
|
|
334
|
+
# ignore
|
|
335
|
+
when 0x08 # BS
|
|
336
|
+
@cursor_col -= 1 if @cursor_col > 0
|
|
337
|
+
@autowrap_pending = false
|
|
338
|
+
when 0x09 # HT
|
|
339
|
+
@cursor_col = [((@cursor_col / 8) + 1) * 8, @cols - 1].min
|
|
340
|
+
@autowrap_pending = false
|
|
341
|
+
when 0x0a, 0x0b, 0x0c # LF
|
|
342
|
+
line_feed
|
|
343
|
+
@autowrap_pending = false
|
|
344
|
+
when 0x0d # CR
|
|
345
|
+
@cursor_col = 0
|
|
346
|
+
@autowrap_pending = false
|
|
347
|
+
when 0x00..0x1f
|
|
348
|
+
# ignore other C0 controls
|
|
349
|
+
else
|
|
350
|
+
put_char(ch)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def escape_char(_ch, b)
|
|
355
|
+
case b
|
|
356
|
+
when 0x5b # [
|
|
357
|
+
@parser_state = :csi
|
|
358
|
+
@parser_params = +""
|
|
359
|
+
when 0x5d # ]
|
|
360
|
+
@parser_state = :osc
|
|
361
|
+
when 0x28, 0x29, 0x2a, 0x2b # ( ) * +
|
|
362
|
+
@parser_state = :charset
|
|
363
|
+
when 0x37 # 7 save cursor
|
|
364
|
+
@saved_cursor = [@cursor_row, @cursor_col]
|
|
365
|
+
@parser_state = :ground
|
|
366
|
+
when 0x38 # 8 restore cursor
|
|
367
|
+
@cursor_row, @cursor_col = @saved_cursor
|
|
368
|
+
@parser_state = :ground
|
|
369
|
+
when 0x44 # D index
|
|
370
|
+
line_feed
|
|
371
|
+
@parser_state = :ground
|
|
372
|
+
when 0x45 # E next line
|
|
373
|
+
@cursor_col = 0
|
|
374
|
+
line_feed
|
|
375
|
+
@parser_state = :ground
|
|
376
|
+
when 0x4d # M reverse index
|
|
377
|
+
if @cursor_row == @scroll_top
|
|
378
|
+
scroll_down_region
|
|
379
|
+
else
|
|
380
|
+
@cursor_row -= 1
|
|
381
|
+
end
|
|
382
|
+
@parser_state = :ground
|
|
383
|
+
when 0x63 # c reset
|
|
384
|
+
reset_terminal
|
|
385
|
+
@parser_state = :ground
|
|
386
|
+
else
|
|
387
|
+
@parser_state = :ground
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def csi_char(_ch, b)
|
|
392
|
+
if (b >= 0x30 && b <= 0x3f) || b == 0x3b
|
|
393
|
+
@parser_params << b.chr
|
|
394
|
+
elsif b >= 0x20 && b <= 0x2f
|
|
395
|
+
@parser_params << b.chr
|
|
396
|
+
elsif b >= 0x40 && b <= 0x7e
|
|
397
|
+
handle_csi(b.chr)
|
|
398
|
+
@parser_state = :ground
|
|
399
|
+
else
|
|
400
|
+
@parser_state = :ground
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def csi_params(default = 0)
|
|
405
|
+
raw = @parser_params.delete_prefix("?").delete_prefix(">").delete_prefix("!")
|
|
406
|
+
raw.split(";", -1).map { |p| p.empty? ? default : p.to_i }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# SGR allows colon-separated subparameters within a single semicolon-delimited
|
|
410
|
+
# piece (e.g. `4:3` for curly underline, `38:2:R:G:B` for RGB foreground,
|
|
411
|
+
# `58:5:N` for an indexed underline color). csi_params collapses these to a
|
|
412
|
+
# single int via `to_i`, which silently turns `4:0` (underline off) into
|
|
413
|
+
# `4` (underline on). Return Integers for plain pieces and Arrays for any
|
|
414
|
+
# piece that contained a colon so apply_sgr can dispatch on the difference.
|
|
415
|
+
def sgr_params
|
|
416
|
+
raw = @parser_params.delete_prefix("?").delete_prefix(">").delete_prefix("!")
|
|
417
|
+
raw.split(";", -1).map do |piece|
|
|
418
|
+
if piece.include?(":")
|
|
419
|
+
piece.split(":", -1).map { |p| p.empty? ? 0 : p.to_i }
|
|
420
|
+
else
|
|
421
|
+
piece.empty? ? 0 : piece.to_i
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def handle_csi(final)
|
|
427
|
+
# Private / extended CSI sequences share final bytes with the standard
|
|
428
|
+
# ones but mean entirely different things. The most damaging example:
|
|
429
|
+
# `\e[>4;2m` (xterm modifyOtherKeys mode 2) shares its final `m` with
|
|
430
|
+
# SGR. If we stripped the `>` and dispatched into apply_sgr, the `4`
|
|
431
|
+
# would latch underline ON globally — Claude Code emits this sequence
|
|
432
|
+
# once at startup and never clears underline afterward, which made the
|
|
433
|
+
# entire UI underlined. Same shape for `\e[<u` (kitty kbd pop),
|
|
434
|
+
# `\e[=...`, `\e[?...r/s` (XTRESTORE/XTSAVE), `\e[!p` (DECSTR).
|
|
435
|
+
case @parser_params[0]
|
|
436
|
+
when ">", "<", "=", "!"
|
|
437
|
+
return
|
|
438
|
+
when "?"
|
|
439
|
+
# DEC private modes — we treat `h`/`l` as no-ops anyway, so dropping
|
|
440
|
+
# everything is safe and avoids `\e[?Nr` colliding with DECSTBM.
|
|
441
|
+
return
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
pms = csi_params
|
|
445
|
+
case final
|
|
446
|
+
when "A"
|
|
447
|
+
n = [pms[0] || 1, 1].max
|
|
448
|
+
@cursor_row = [@cursor_row - n, 0].max
|
|
449
|
+
@autowrap_pending = false
|
|
450
|
+
when "B", "e"
|
|
451
|
+
n = [pms[0] || 1, 1].max
|
|
452
|
+
@cursor_row = [@cursor_row + n, @rows - 1].min
|
|
453
|
+
@autowrap_pending = false
|
|
454
|
+
when "C", "a"
|
|
455
|
+
n = [pms[0] || 1, 1].max
|
|
456
|
+
@cursor_col = [@cursor_col + n, @cols - 1].min
|
|
457
|
+
@autowrap_pending = false
|
|
458
|
+
when "D"
|
|
459
|
+
n = [pms[0] || 1, 1].max
|
|
460
|
+
@cursor_col = [@cursor_col - n, 0].max
|
|
461
|
+
@autowrap_pending = false
|
|
462
|
+
when "E"
|
|
463
|
+
n = [pms[0] || 1, 1].max
|
|
464
|
+
@cursor_row = [@cursor_row + n, @rows - 1].min
|
|
465
|
+
@cursor_col = 0
|
|
466
|
+
@autowrap_pending = false
|
|
467
|
+
when "F"
|
|
468
|
+
n = [pms[0] || 1, 1].max
|
|
469
|
+
@cursor_row = [@cursor_row - n, 0].max
|
|
470
|
+
@cursor_col = 0
|
|
471
|
+
@autowrap_pending = false
|
|
472
|
+
when "G", "`"
|
|
473
|
+
@cursor_col = ((pms[0] || 1) - 1).clamp(0, @cols - 1)
|
|
474
|
+
@autowrap_pending = false
|
|
475
|
+
when "d"
|
|
476
|
+
@cursor_row = ((pms[0] || 1) - 1).clamp(0, @rows - 1)
|
|
477
|
+
@autowrap_pending = false
|
|
478
|
+
when "H", "f"
|
|
479
|
+
row = (pms[0] || 1) - 1
|
|
480
|
+
col = (pms[1] || 1) - 1
|
|
481
|
+
@cursor_row = row.clamp(0, @rows - 1)
|
|
482
|
+
@cursor_col = col.clamp(0, @cols - 1)
|
|
483
|
+
@autowrap_pending = false
|
|
484
|
+
when "J"
|
|
485
|
+
erase_display(pms[0] || 0)
|
|
486
|
+
when "K"
|
|
487
|
+
erase_line(pms[0] || 0)
|
|
488
|
+
when "L"
|
|
489
|
+
insert_lines(pms[0] || 1)
|
|
490
|
+
when "M"
|
|
491
|
+
delete_lines(pms[0] || 1)
|
|
492
|
+
when "P"
|
|
493
|
+
delete_chars(pms[0] || 1)
|
|
494
|
+
when "@"
|
|
495
|
+
insert_chars(pms[0] || 1)
|
|
496
|
+
when "X"
|
|
497
|
+
n = [pms[0] || 1, 1].max
|
|
498
|
+
n.times do |i|
|
|
499
|
+
c = @cursor_col + i
|
|
500
|
+
@buffer[@cursor_row][c].reset! if c < @cols
|
|
501
|
+
end
|
|
502
|
+
when "r"
|
|
503
|
+
top = ((pms[0] || 1) - 1).clamp(0, @rows - 1)
|
|
504
|
+
bottom = ((pms[1] || @rows) - 1).clamp(top, @rows - 1)
|
|
505
|
+
@scroll_top = top
|
|
506
|
+
@scroll_bottom = bottom
|
|
507
|
+
@cursor_row = 0
|
|
508
|
+
@cursor_col = 0
|
|
509
|
+
@autowrap_pending = false
|
|
510
|
+
when "m"
|
|
511
|
+
apply_sgr(sgr_params)
|
|
512
|
+
when "s"
|
|
513
|
+
@saved_cursor = [@cursor_row, @cursor_col]
|
|
514
|
+
when "u"
|
|
515
|
+
@cursor_row, @cursor_col = @saved_cursor
|
|
516
|
+
when "h", "l"
|
|
517
|
+
# Non-private mode set/reset — nothing we need to honor. (DEC private
|
|
518
|
+
# `?`-prefixed mode sequences are short-circuited above.)
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def put_char(ch)
|
|
523
|
+
if @autowrap_pending
|
|
524
|
+
@cursor_col = 0
|
|
525
|
+
line_feed
|
|
526
|
+
@autowrap_pending = false
|
|
527
|
+
end
|
|
528
|
+
cell = @buffer[@cursor_row][@cursor_col]
|
|
529
|
+
cell.char = ch
|
|
530
|
+
cell.fg = @fg
|
|
531
|
+
cell.bg = @bg
|
|
532
|
+
cell.attrs = @attrs
|
|
533
|
+
if @cursor_col >= @cols - 1
|
|
534
|
+
@autowrap_pending = true
|
|
535
|
+
else
|
|
536
|
+
@cursor_col += 1
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def line_feed
|
|
541
|
+
if @cursor_row == @scroll_bottom
|
|
542
|
+
scroll_up_region
|
|
543
|
+
elsif @cursor_row < @rows - 1
|
|
544
|
+
@cursor_row += 1
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def scroll_up_region
|
|
549
|
+
# Only the default full-screen region contributes to scrollback. Partial
|
|
550
|
+
# regions (vi/less status lines) scroll inner content that's not really
|
|
551
|
+
# "off the top of the screen" and shouldn't pollute history.
|
|
552
|
+
if @scroll_top.zero? && @scroll_bottom == @rows - 1
|
|
553
|
+
@scrollback << @buffer[0]
|
|
554
|
+
if @scrollback.size > SCROLLBACK_MAX
|
|
555
|
+
@scrollback.shift
|
|
556
|
+
# Selection coordinates are timeline-indexed; an eviction shifts the
|
|
557
|
+
# whole timeline down by one. Track that or selection points at the
|
|
558
|
+
# wrong row.
|
|
559
|
+
if @selection_anchor
|
|
560
|
+
@selection_anchor[0] = [@selection_anchor[0] - 1, 0].max
|
|
561
|
+
@selection_cursor[0] = [@selection_cursor[0] - 1, 0].max
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
# Keep the user's view frozen on the same content when new rows arrive
|
|
565
|
+
# while they're scrolled back.
|
|
566
|
+
if @view_offset.positive?
|
|
567
|
+
@view_offset = (@view_offset + 1).clamp(0, @scrollback.size)
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
@buffer[@scroll_top, @scroll_bottom - @scroll_top + 1] =
|
|
571
|
+
@buffer[(@scroll_top + 1)..@scroll_bottom] + [Array.new(@cols) { blank_cell }]
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def set_view_offset(v)
|
|
575
|
+
new_v = v.clamp(0, @scrollback.size)
|
|
576
|
+
return if new_v == @view_offset
|
|
577
|
+
@view_offset = new_v
|
|
578
|
+
@dirty = true
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def timeline_size
|
|
582
|
+
@scrollback.size + @rows
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def timeline_row(tr)
|
|
586
|
+
if tr < @scrollback.size
|
|
587
|
+
@scrollback[tr]
|
|
588
|
+
else
|
|
589
|
+
@buffer[tr - @scrollback.size]
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def timeline_row_for_visible(r)
|
|
594
|
+
@scrollback.size - @view_offset + r
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def ordered_selection
|
|
598
|
+
a = @selection_anchor
|
|
599
|
+
b = @selection_cursor
|
|
600
|
+
if a[0] < b[0] || (a[0] == b[0] && a[1] <= b[1])
|
|
601
|
+
[a[0], a[1], b[0], b[1]]
|
|
602
|
+
else
|
|
603
|
+
[b[0], b[1], a[0], a[1]]
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def inside_selection?(tr, c)
|
|
608
|
+
if @selection_mode == :block
|
|
609
|
+
ar, ac = @selection_anchor
|
|
610
|
+
br, bc = @selection_cursor
|
|
611
|
+
min_r, max_r = ar <= br ? [ar, br] : [br, ar]
|
|
612
|
+
min_c, max_c = ac <= bc ? [ac, bc] : [bc, ac]
|
|
613
|
+
return tr.between?(min_r, max_r) && c.between?(min_c, max_c)
|
|
614
|
+
end
|
|
615
|
+
sr, sc, er, ec = ordered_selection
|
|
616
|
+
return false if tr < sr || tr > er
|
|
617
|
+
if sr == er
|
|
618
|
+
c.between?(sc, ec)
|
|
619
|
+
elsif tr == sr
|
|
620
|
+
c >= sc
|
|
621
|
+
elsif tr == er
|
|
622
|
+
c <= ec
|
|
623
|
+
else
|
|
624
|
+
true
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def ensure_selection_cursor_visible
|
|
629
|
+
return unless @selection_cursor
|
|
630
|
+
tr = @selection_cursor[0]
|
|
631
|
+
top = @scrollback.size - @view_offset
|
|
632
|
+
bottom = top + @rows - 1
|
|
633
|
+
if tr < top
|
|
634
|
+
set_view_offset(@scrollback.size - tr)
|
|
635
|
+
elsif tr > bottom
|
|
636
|
+
set_view_offset(@scrollback.size - tr + @rows - 1)
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def scroll_down_region
|
|
641
|
+
@buffer[@scroll_top, @scroll_bottom - @scroll_top + 1] =
|
|
642
|
+
[Array.new(@cols) { blank_cell }] + @buffer[@scroll_top..(@scroll_bottom - 1)]
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def erase_display(mode)
|
|
646
|
+
case mode
|
|
647
|
+
when 0
|
|
648
|
+
(@cursor_col...@cols).each { |c| @buffer[@cursor_row][c].reset! }
|
|
649
|
+
((@cursor_row + 1)...@rows).each do |r|
|
|
650
|
+
@buffer[r].each(&:reset!)
|
|
651
|
+
end
|
|
652
|
+
when 1
|
|
653
|
+
(0..@cursor_col).each { |c| @buffer[@cursor_row][c].reset! }
|
|
654
|
+
(0...@cursor_row).each { |r| @buffer[r].each(&:reset!) }
|
|
655
|
+
when 2, 3
|
|
656
|
+
@buffer.each { |row| row.each(&:reset!) }
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def erase_line(mode)
|
|
661
|
+
case mode
|
|
662
|
+
when 0
|
|
663
|
+
(@cursor_col...@cols).each { |c| @buffer[@cursor_row][c].reset! }
|
|
664
|
+
when 1
|
|
665
|
+
(0..@cursor_col).each { |c| @buffer[@cursor_row][c].reset! }
|
|
666
|
+
when 2
|
|
667
|
+
@buffer[@cursor_row].each(&:reset!)
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def insert_lines(n)
|
|
672
|
+
return unless @cursor_row.between?(@scroll_top, @scroll_bottom)
|
|
673
|
+
n = [n, @scroll_bottom - @cursor_row + 1].min
|
|
674
|
+
n.times do
|
|
675
|
+
@buffer.insert(@cursor_row, Array.new(@cols) { blank_cell })
|
|
676
|
+
@buffer.delete_at(@scroll_bottom + 1)
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def delete_lines(n)
|
|
681
|
+
return unless @cursor_row.between?(@scroll_top, @scroll_bottom)
|
|
682
|
+
n = [n, @scroll_bottom - @cursor_row + 1].min
|
|
683
|
+
n.times do
|
|
684
|
+
@buffer.delete_at(@cursor_row)
|
|
685
|
+
@buffer.insert(@scroll_bottom, Array.new(@cols) { blank_cell })
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def delete_chars(n)
|
|
690
|
+
n = [n, @cols - @cursor_col].min
|
|
691
|
+
n.times do
|
|
692
|
+
@buffer[@cursor_row].delete_at(@cursor_col)
|
|
693
|
+
@buffer[@cursor_row].push(blank_cell)
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def insert_chars(n)
|
|
698
|
+
n = [n, @cols - @cursor_col].min
|
|
699
|
+
n.times do
|
|
700
|
+
@buffer[@cursor_row].insert(@cursor_col, blank_cell)
|
|
701
|
+
@buffer[@cursor_row].pop
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def apply_sgr(tokens)
|
|
706
|
+
tokens = [0] if tokens.empty?
|
|
707
|
+
i = 0
|
|
708
|
+
while i < tokens.length
|
|
709
|
+
t = tokens[i]
|
|
710
|
+
if t.is_a?(Array)
|
|
711
|
+
apply_sgr_colon(t)
|
|
712
|
+
i += 1
|
|
713
|
+
next
|
|
714
|
+
end
|
|
715
|
+
p = t
|
|
716
|
+
case p
|
|
717
|
+
when 0
|
|
718
|
+
@fg = nil
|
|
719
|
+
@bg = nil
|
|
720
|
+
@attrs = 0
|
|
721
|
+
when 1 then @attrs |= BOLD
|
|
722
|
+
when 4 then @attrs |= UNDERLINE
|
|
723
|
+
when 7 then @attrs |= REVERSE
|
|
724
|
+
when 22 then @attrs &= ~BOLD
|
|
725
|
+
when 24 then @attrs &= ~UNDERLINE
|
|
726
|
+
when 27 then @attrs &= ~REVERSE
|
|
727
|
+
when 30..37 then @fg = p - 30
|
|
728
|
+
when 38
|
|
729
|
+
if tokens[i + 1] == 5
|
|
730
|
+
@fg = [:c256, tokens[i + 2]]
|
|
731
|
+
i += 2
|
|
732
|
+
elsif tokens[i + 1] == 2
|
|
733
|
+
@fg = [:rgb, tokens[i + 2], tokens[i + 3], tokens[i + 4]]
|
|
734
|
+
i += 4
|
|
735
|
+
end
|
|
736
|
+
when 39 then @fg = nil
|
|
737
|
+
when 40..47 then @bg = p - 40
|
|
738
|
+
when 48
|
|
739
|
+
if tokens[i + 1] == 5
|
|
740
|
+
@bg = [:c256, tokens[i + 2]]
|
|
741
|
+
i += 2
|
|
742
|
+
elsif tokens[i + 1] == 2
|
|
743
|
+
@bg = [:rgb, tokens[i + 2], tokens[i + 3], tokens[i + 4]]
|
|
744
|
+
i += 4
|
|
745
|
+
end
|
|
746
|
+
when 49 then @bg = nil
|
|
747
|
+
when 58
|
|
748
|
+
# Set underline color. We don't render underline color separately,
|
|
749
|
+
# but the params must be consumed or they'll be re-interpreted as
|
|
750
|
+
# standalone SGR codes (e.g. an R/G/B value of 4 would spuriously
|
|
751
|
+
# turn on underline for every cell that follows).
|
|
752
|
+
if tokens[i + 1] == 5
|
|
753
|
+
i += 2
|
|
754
|
+
elsif tokens[i + 1] == 2
|
|
755
|
+
i += 4
|
|
756
|
+
end
|
|
757
|
+
when 59
|
|
758
|
+
# Default underline color — nothing to track.
|
|
759
|
+
when 90..97 then @fg = p - 90 + 8
|
|
760
|
+
when 100..107 then @bg = p - 100 + 8
|
|
761
|
+
end
|
|
762
|
+
i += 1
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def apply_sgr_colon(parts)
|
|
767
|
+
return if parts.empty?
|
|
768
|
+
case parts[0]
|
|
769
|
+
when 4
|
|
770
|
+
# `4:0` disables underline; `4:1..5` selects a style (straight, double,
|
|
771
|
+
# curly, dotted, dashed) — we render them all as plain underline.
|
|
772
|
+
if parts[1] == 0
|
|
773
|
+
@attrs &= ~UNDERLINE
|
|
774
|
+
else
|
|
775
|
+
@attrs |= UNDERLINE
|
|
776
|
+
end
|
|
777
|
+
when 24
|
|
778
|
+
@attrs &= ~UNDERLINE
|
|
779
|
+
when 38
|
|
780
|
+
apply_extended_color(parts, foreground: true)
|
|
781
|
+
when 48
|
|
782
|
+
apply_extended_color(parts, foreground: false)
|
|
783
|
+
when 58
|
|
784
|
+
# Underline color — ignored, but consumed.
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def apply_extended_color(parts, foreground:)
|
|
789
|
+
case parts[1]
|
|
790
|
+
when 5
|
|
791
|
+
color = [:c256, parts[2] || 0]
|
|
792
|
+
foreground ? @fg = color : @bg = color
|
|
793
|
+
when 2
|
|
794
|
+
# ITU T.416 allows an optional colorspace id, giving `38:2::R:G:B`
|
|
795
|
+
# (length 6) rather than `38:2:R:G:B` (length 5).
|
|
796
|
+
rgb_start = parts.length >= 6 ? 3 : 2
|
|
797
|
+
r = parts[rgb_start] || 0
|
|
798
|
+
g = parts[rgb_start + 1] || 0
|
|
799
|
+
b = parts[rgb_start + 2] || 0
|
|
800
|
+
color = [:rgb, r, g, b]
|
|
801
|
+
foreground ? @fg = color : @bg = color
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def reset_terminal
|
|
806
|
+
@buffer = Array.new(@rows) { Array.new(@cols) { blank_cell } }
|
|
807
|
+
@cursor_row = 0
|
|
808
|
+
@cursor_col = 0
|
|
809
|
+
@fg = nil
|
|
810
|
+
@bg = nil
|
|
811
|
+
@attrs = 0
|
|
812
|
+
@scroll_top = 0
|
|
813
|
+
@scroll_bottom = @rows - 1
|
|
814
|
+
@autowrap_pending = false
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
end
|