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