echoes 0.2.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,1468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Echoes
6
+ class Screen
7
+ attr_reader :rows, :cols, :cursor, :grid, :scrollback, :dirty_rows,
8
+ :command_marks
9
+ attr_accessor :cell_pixel_width, :cell_pixel_height, :title, :current_directory,
10
+ :pending_wrap, :background, :bg_fills
11
+
12
+ def self.scrollback_limit
13
+ Echoes.config.scrollback_limit
14
+ end
15
+
16
+ def initialize(rows: 24, cols: 80)
17
+ @rows = rows
18
+ @cols = cols
19
+ @cursor = Cursor.new
20
+ @attrs = Cell.new
21
+ @grid = Array.new(rows) { Array.new(cols) { Cell.new } }
22
+ @line_wrapped = Array.new(rows, false)
23
+ @scroll_top = 0
24
+ @scroll_bottom = rows - 1
25
+ @saved_cursor = nil
26
+ @scrollback = []
27
+ @scrollback_wrapped = []
28
+ @cell_pixel_width = 8.0
29
+ @cell_pixel_height = 16.0
30
+ @application_cursor_keys = false
31
+ @bracketed_paste_mode = false
32
+ @focus_reporting = false
33
+ @auto_wrap = true
34
+ @mouse_tracking = :off # :off, :x10, :normal, :button_event, :any_event
35
+ @mouse_encoding = :default # :default, :sgr
36
+ @origin_mode = false
37
+ @insert_mode = false
38
+ @application_keypad = false
39
+ @cursor_style = 0 # 0=default, 1=blinking block, 2=steady block, 3=blinking underline, 4=steady underline, 5=blinking bar, 6=steady bar
40
+ @using_alt_screen = false
41
+ @charset_g0 = :ascii # :ascii or :dec_special
42
+ @charset_g1 = :ascii
43
+ @charset_g2 = :ascii
44
+ @charset_g3 = :ascii
45
+ @active_charset = 0 # 0 = G0, 1 = G1
46
+ @single_shift = nil # nil, 2, or 3 (for SS2/SS3)
47
+ @tab_stops = default_tab_stops
48
+ @main_grid = nil
49
+ @main_cursor = nil
50
+ @main_scroll_top = nil
51
+ @main_scroll_bottom = nil
52
+ @main_saved_cursor = nil
53
+ @main_scrollback = nil
54
+ @pending_wrap = false
55
+ @last_char = nil
56
+ @title_stack = []
57
+ @dirty_rows = Set.new((0...rows).to_a)
58
+ @bg_fills = [] # OSC 7772 ;bg-fill regions; each: {rect:[r1,c1,r2,c2], color:[r,g,b,a]}
59
+ # OSC 133 prompt-boundary markers: each entry is a Hash with
60
+ # :prompt_start / :input_start / :output_start / :output_end /
61
+ # :exit_code keys, where the row values are *visual* row indices —
62
+ # `scrollback_size + grid_row` at the moment the marker was seen.
63
+ # When scrollback shifts off the front, mark rows decrement so
64
+ # they keep pointing at the same content; marks that fall before
65
+ # the scrollback floor are dropped.
66
+ @command_marks = []
67
+ @current_command_mark = nil
68
+ end
69
+
70
+ DEC_SPECIAL = {
71
+ '`' => "\u{25C6}", 'a' => "\u{2592}", 'b' => "\u{2409}", 'c' => "\u{240C}",
72
+ 'd' => "\u{240D}", 'e' => "\u{240A}", 'f' => "\u{00B0}", 'g' => "\u{00B1}",
73
+ 'h' => "\u{2424}", 'i' => "\u{240B}", 'j' => "\u{2518}", 'k' => "\u{2510}",
74
+ 'l' => "\u{250C}", 'm' => "\u{2514}", 'n' => "\u{253C}", 'o' => "\u{23BA}",
75
+ 'p' => "\u{23BB}", 'q' => "\u{2500}", 'r' => "\u{23BC}", 's' => "\u{23BD}",
76
+ 't' => "\u{251C}", 'u' => "\u{2524}", 'v' => "\u{2534}", 'w' => "\u{252C}",
77
+ 'x' => "\u{2502}", 'y' => "\u{2264}", 'z' => "\u{2265}", '{' => "\u{03C0}",
78
+ '|' => "\u{2260}", '}' => "\u{00A3}", '~' => "\u{00B7}",
79
+ }.freeze
80
+
81
+ def put_char(c)
82
+ if c.bytesize == 1
83
+ if @single_shift
84
+ cs = @single_shift == 2 ? @charset_g2 : @charset_g3
85
+ @single_shift = nil
86
+ else
87
+ cs = @active_charset == 0 ? @charset_g0 : @charset_g1
88
+ end
89
+ if cs == :dec_special
90
+ c = DEC_SPECIAL.fetch(c, c)
91
+ end
92
+ end
93
+
94
+ # Combining characters: append to previous cell
95
+ if combining?(c)
96
+ col = @pending_wrap ? @cursor.col : [0, @cursor.col - 1].max
97
+ col -= 1 if col > 0 && @grid[@cursor.row][col].width == 0
98
+ @grid[@cursor.row][col].char += c
99
+ @last_char = @grid[@cursor.row][col].char
100
+ return
101
+ end
102
+
103
+ w = char_width(c)
104
+
105
+ if @auto_wrap
106
+ # Deferred wrap: if the previous character set the flag, wrap now
107
+ if @pending_wrap
108
+ @pending_wrap = false
109
+ @line_wrapped[@cursor.row] = true
110
+ @cursor.col = 0
111
+ line_feed
112
+ end
113
+
114
+ # Wide char at last column: doesn't fit, wrap first
115
+ if w == 2 && @cursor.col == @cols - 1
116
+ @grid[@cursor.row][@cursor.col].reset!
117
+ @line_wrapped[@cursor.row] = true
118
+ @cursor.col = 0
119
+ line_feed
120
+ end
121
+ else
122
+ # No wrap: clamp cursor to last column
123
+ if w == 2 && @cursor.col >= @cols - 1
124
+ @cursor.col = @cols - 2
125
+ elsif @cursor.col >= @cols
126
+ @cursor.col = @cols - 1
127
+ end
128
+ end
129
+
130
+ erase_multicell_at(@cursor.row, @cursor.col)
131
+
132
+ if @insert_mode
133
+ row = @grid[@cursor.row]
134
+ w.times { row.pop; row.insert(@cursor.col, Cell.new) }
135
+ end
136
+
137
+ cell = @grid[@cursor.row][@cursor.col]
138
+ cell.copy_from(@attrs)
139
+ cell.char = c
140
+ cell.width = w
141
+
142
+ if w == 2 && @cursor.col + 1 < @cols
143
+ # Mark the next cell as a continuation (width 0)
144
+ next_cell = @grid[@cursor.row][@cursor.col + 1]
145
+ next_cell.reset!
146
+ next_cell.width = 0
147
+ end
148
+
149
+ mark_dirty(@cursor.row)
150
+
151
+ @cursor.col += w
152
+ if @cursor.col >= @cols
153
+ @cursor.col = @cols - 1
154
+ @pending_wrap = true if @auto_wrap
155
+ end
156
+
157
+ @last_char = c
158
+ end
159
+
160
+ def repeat_char(n = 1)
161
+ return unless @last_char
162
+
163
+ n.times { put_char(@last_char) }
164
+ end
165
+
166
+ def put_multicell(text, scale:, width:, frac_n:, frac_d:, valign:, halign:, family: nil)
167
+ mc_rows = scale
168
+
169
+ if width > 0
170
+ # Explicit width: entire text in one block of scale*width cols × scale rows
171
+ place_multicell_block(text, scale * width, mc_rows, scale, frac_n, frac_d, valign, halign, family)
172
+ elsif halign != 0
173
+ # h= is set: render the whole string as one block of
174
+ # `scale × source_chars` cells, so the renderer's halign math
175
+ # has room to center / right-align the glyphs. The spec only
176
+ # mandates h= when the glyphs are smaller than the block
177
+ # (fractional n<d), but extending it to non-fractional /
178
+ # proportional text is a natural superset — other terminals
179
+ # just ignore the attribute. With a proportional family we
180
+ # widen the block to `max(scale × source_chars, measured)` so
181
+ # the text never overflows but still gets visible side
182
+ # margins for centering.
183
+ source_chars = text.each_grapheme_cluster.sum { |g| char_width(g) }
184
+ mc_cols = scale * source_chars
185
+ if family && @glyph_measurer && @cell_pixel_width && @cell_pixel_width > 0
186
+ measured_px = @glyph_measurer.call(text, family, scale, frac_n, frac_d).to_f
187
+ measured_cells = (measured_px / @cell_pixel_width).ceil
188
+ mc_cols = [mc_cols, measured_cells].max
189
+ end
190
+ mc_cols = [mc_cols, 1].max
191
+ place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family)
192
+ elsif family && @glyph_measurer && @cell_pixel_width && @cell_pixel_width > 0
193
+ # Proportional fonts have variable glyph widths, so reserving
194
+ # `char_width(grapheme) * scale` cells per grapheme leaves big
195
+ # letters (Noto Serif "H" at 2×) overflowing into the next
196
+ # cell and small letters ("l") under-filling theirs. Ask the
197
+ # host to measure the whole text in the requested font and
198
+ # reserve enough cells for the entire block, drawn as one
199
+ # unit by the renderer's existing string-draw path.
200
+ measured_px = @glyph_measurer.call(text, family, scale, frac_n, frac_d).to_f
201
+ mc_cols = (measured_px / @cell_pixel_width).ceil
202
+ mc_cols = [mc_cols, 1].max
203
+ place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family)
204
+ else
205
+ # Auto width: each grapheme gets its own block (monospace
206
+ # assumption — fine for the configured terminal font).
207
+ text.each_grapheme_cluster do |grapheme|
208
+ cw = char_width(grapheme)
209
+ mc_cols = scale * cw
210
+ place_multicell_block(grapheme, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family)
211
+ end
212
+ end
213
+ end
214
+
215
+ def put_sixel(data, params)
216
+ decoder = SixelDecoder.new(params).decode(data)
217
+ return if decoder.width == 0 || decoder.height == 0
218
+
219
+ mc_cols = (decoder.width / @cell_pixel_width).ceil
220
+ mc_rows = (decoder.height / @cell_pixel_height).ceil
221
+
222
+ return if mc_cols > @cols || mc_rows > @rows
223
+
224
+ # Wrap if it doesn't fit on current line
225
+ if @cursor.col + mc_cols > @cols
226
+ @cursor.col = 0
227
+ line_feed
228
+ end
229
+
230
+ # Scroll if block doesn't fit vertically
231
+ while @cursor.row + mc_rows > @rows
232
+ scroll_up(1)
233
+ @cursor.row = [@cursor.row - 1, 0].max
234
+ end
235
+
236
+ anchor_row = @cursor.row
237
+ anchor_col = @cursor.col
238
+
239
+ # Erase existing cells in the block area
240
+ mc_rows.times do |dr|
241
+ mc_cols.times do |dc|
242
+ erase_multicell_at(anchor_row + dr, anchor_col + dc)
243
+ end
244
+ end
245
+
246
+ # Set anchor cell with sixel data
247
+ anchor = @grid[anchor_row][anchor_col]
248
+ anchor.reset!
249
+ anchor.char = " "
250
+ anchor.width = 1
251
+ anchor.multicell = {
252
+ cols: mc_cols, rows: mc_rows, scale: 1,
253
+ frac_n: 0, frac_d: 0, valign: 0, halign: 0,
254
+ sixel: { width: decoder.width, height: decoder.height, rgba: decoder.to_rgba }
255
+ }
256
+
257
+ # Mark continuation cells
258
+ mc_rows.times do |dr|
259
+ mc_cols.times do |dc|
260
+ next if dr == 0 && dc == 0
261
+ cont = @grid[anchor_row + dr][anchor_col + dc]
262
+ cont.reset!
263
+ cont.multicell = :cont
264
+ end
265
+ end
266
+
267
+ @cursor.col = 0
268
+ @cursor.row = [anchor_row + mc_rows, @rows - 1].min
269
+ end
270
+
271
+ def move_cursor(row, col)
272
+ @pending_wrap = false
273
+ if @origin_mode
274
+ @cursor.row = (row + @scroll_top).clamp(@scroll_top, @scroll_bottom)
275
+ else
276
+ @cursor.row = clamp_row(row)
277
+ end
278
+ @cursor.col = clamp_col(col)
279
+ end
280
+
281
+ def move_cursor_up(n = 1)
282
+ @pending_wrap = false
283
+ top = @cursor.row >= @scroll_top ? @scroll_top : 0
284
+ @cursor.row = [top, @cursor.row - n].max
285
+ end
286
+
287
+ def move_cursor_down(n = 1)
288
+ @pending_wrap = false
289
+ bottom = @cursor.row <= @scroll_bottom ? @scroll_bottom : @rows - 1
290
+ @cursor.row = [bottom, @cursor.row + n].min
291
+ end
292
+
293
+ def move_cursor_next_line(n = 1)
294
+ @pending_wrap = false
295
+ bottom = @cursor.row <= @scroll_bottom ? @scroll_bottom : @rows - 1
296
+ @cursor.row = [bottom, @cursor.row + n].min
297
+ @cursor.col = 0
298
+ end
299
+
300
+ def move_cursor_prev_line(n = 1)
301
+ @pending_wrap = false
302
+ top = @cursor.row >= @scroll_top ? @scroll_top : 0
303
+ @cursor.row = [top, @cursor.row - n].max
304
+ @cursor.col = 0
305
+ end
306
+
307
+ def move_cursor_forward(n = 1)
308
+ @pending_wrap = false
309
+ @cursor.col = [@cols - 1, @cursor.col + n].min
310
+ end
311
+
312
+ def move_cursor_backward(n = 1)
313
+ @pending_wrap = false
314
+ @cursor.col = [0, @cursor.col - n].max
315
+ end
316
+
317
+ def carriage_return
318
+ @pending_wrap = false
319
+ @cursor.col = 0
320
+ end
321
+
322
+ def line_feed
323
+ @pending_wrap = false
324
+ if @cursor.row == @scroll_bottom
325
+ scroll_up(1)
326
+ else
327
+ @cursor.row = [@cursor.row + 1, @rows - 1].min
328
+ end
329
+ end
330
+
331
+ def reverse_index
332
+ @pending_wrap = false
333
+ if @cursor.row == @scroll_top
334
+ scroll_down(1)
335
+ else
336
+ @cursor.row = [0, @cursor.row - 1].max
337
+ end
338
+ end
339
+
340
+ def tab
341
+ @pending_wrap = false
342
+ next_stop = @tab_stops.find { |s| s > @cursor.col }
343
+ @cursor.col = next_stop ? [next_stop, @cols - 1].min : @cols - 1
344
+ end
345
+
346
+ def backward_tab(n = 1)
347
+ @pending_wrap = false
348
+ n.times do
349
+ prev_stop = @tab_stops.reverse.find { |s| s < @cursor.col }
350
+ @cursor.col = prev_stop || 0
351
+ end
352
+ end
353
+
354
+ def set_tab_stop
355
+ @tab_stops << @cursor.col unless @tab_stops.include?(@cursor.col)
356
+ @tab_stops.sort!
357
+ end
358
+
359
+ def clear_tab_stop(mode = 0)
360
+ case mode
361
+ when 0
362
+ @tab_stops.delete(@cursor.col)
363
+ when 3
364
+ @tab_stops.clear
365
+ end
366
+ end
367
+
368
+ def backspace
369
+ @pending_wrap = false
370
+ @cursor.col = [0, @cursor.col - 1].max
371
+ end
372
+
373
+ def erase_in_display(mode = 0)
374
+ @pending_wrap = false
375
+ case mode
376
+ when 0
377
+ @line_wrapped[@cursor.row] = false
378
+ erase_in_line(0)
379
+ ((@cursor.row + 1)...@rows).each { |r| clear_row(r); @line_wrapped[r] = false; mark_dirty(r) }
380
+ when 1
381
+ erase_in_line(1)
382
+ (0...@cursor.row).each { |r| clear_row(r); @line_wrapped[r] = false; mark_dirty(r) }
383
+ when 2
384
+ (0...@rows).each { |r| clear_row(r); @line_wrapped[r] = false }
385
+ mark_all_dirty
386
+ when 3
387
+ @scrollback.clear
388
+ @scrollback_wrapped.clear
389
+ end
390
+ end
391
+
392
+ def erase_in_line(mode = 0)
393
+ @pending_wrap = false
394
+ mark_dirty(@cursor.row)
395
+ case mode
396
+ when 0
397
+ (@cursor.col...@cols).each { |c| @grid[@cursor.row][c].reset! }
398
+ when 1
399
+ (0..@cursor.col).each { |c| @grid[@cursor.row][c].reset! }
400
+ when 2
401
+ clear_row(@cursor.row)
402
+ end
403
+ end
404
+
405
+ def insert_lines(n = 1)
406
+ @pending_wrap = false
407
+ return unless @cursor.row >= @scroll_top && @cursor.row <= @scroll_bottom
408
+
409
+ n.times do
410
+ @grid.insert(@cursor.row, Array.new(@cols) { Cell.new })
411
+ @line_wrapped.insert(@cursor.row, false)
412
+ @grid.delete_at(@scroll_bottom + 1)
413
+ @line_wrapped.delete_at(@scroll_bottom + 1)
414
+ end
415
+ (@cursor.row..@scroll_bottom).each { |r| mark_dirty(r) }
416
+ end
417
+
418
+ def delete_lines(n = 1)
419
+ @pending_wrap = false
420
+ return unless @cursor.row >= @scroll_top && @cursor.row <= @scroll_bottom
421
+
422
+ n.times do
423
+ @grid.delete_at(@cursor.row)
424
+ @line_wrapped.delete_at(@cursor.row)
425
+ @grid.insert(@scroll_bottom, Array.new(@cols) { Cell.new })
426
+ @line_wrapped.insert(@scroll_bottom, false)
427
+ end
428
+ (@cursor.row..@scroll_bottom).each { |r| mark_dirty(r) }
429
+ end
430
+
431
+ def delete_chars(n = 1)
432
+ @pending_wrap = false
433
+ row = @grid[@cursor.row]
434
+ n.times do
435
+ row.delete_at(@cursor.col)
436
+ row.push(Cell.new)
437
+ end
438
+ mark_dirty(@cursor.row)
439
+ end
440
+
441
+ def insert_chars(n = 1)
442
+ @pending_wrap = false
443
+ row = @grid[@cursor.row]
444
+ n.times do
445
+ row.pop
446
+ row.insert(@cursor.col, Cell.new)
447
+ end
448
+ mark_dirty(@cursor.row)
449
+ end
450
+
451
+ def erase_chars(n = 1)
452
+ n.times do |i|
453
+ col = @cursor.col + i
454
+ break if col >= @cols
455
+ @grid[@cursor.row][col].reset!
456
+ end
457
+ mark_dirty(@cursor.row)
458
+ end
459
+
460
+ def scroll_up(n = 1)
461
+ @pending_wrap = false
462
+ n.times do
463
+ if @scroll_top == 0
464
+ row = @grid[@scroll_top]
465
+ @scrollback << row.map { |cell| c = Cell.new; c.copy_from(cell); c.width = cell.width; c.multicell = cell.multicell; c }
466
+ @scrollback_wrapped << @line_wrapped[@scroll_top]
467
+ if @scrollback.size > self.class.scrollback_limit
468
+ @scrollback.shift
469
+ adjust_command_marks(-1)
470
+ end
471
+ @scrollback_wrapped.shift if @scrollback_wrapped.size > self.class.scrollback_limit
472
+ end
473
+ @grid.delete_at(@scroll_top)
474
+ @line_wrapped.delete_at(@scroll_top)
475
+ @grid.insert(@scroll_bottom, Array.new(@cols) { Cell.new })
476
+ @line_wrapped.insert(@scroll_bottom, false)
477
+ end
478
+ (@scroll_top..@scroll_bottom).each { |r| mark_dirty(r) }
479
+ end
480
+
481
+ def scroll_down(n = 1)
482
+ @pending_wrap = false
483
+ n.times do
484
+ @grid.delete_at(@scroll_bottom)
485
+ @line_wrapped.delete_at(@scroll_bottom)
486
+ @grid.insert(@scroll_top, Array.new(@cols) { Cell.new })
487
+ @line_wrapped.insert(@scroll_top, false)
488
+ end
489
+ (@scroll_top..@scroll_bottom).each { |r| mark_dirty(r) }
490
+ end
491
+
492
+ def set_scroll_region(top, bottom)
493
+ @pending_wrap = false
494
+ @scroll_top = clamp_row(top)
495
+ @scroll_bottom = clamp_row(bottom)
496
+ @cursor.row = 0
497
+ @cursor.col = 0
498
+ end
499
+
500
+ def set_graphics(params)
501
+ params = [0] if params.empty?
502
+ i = 0
503
+ while i < params.length
504
+ p = params[i]
505
+
506
+ # Handle colon sub-parameter arrays (e.g. [38, 2, nil, R, G, B])
507
+ if p.is_a?(Array)
508
+ apply_sgr_subparams(p)
509
+ i += 1
510
+ next
511
+ end
512
+
513
+ case p
514
+ when 0, nil
515
+ @attrs.reset!
516
+ when 1
517
+ @attrs.bold = true
518
+ when 2
519
+ @attrs.faint = true
520
+ when 3
521
+ @attrs.italic = true
522
+ when 4
523
+ @attrs.underline = true
524
+ when 7
525
+ @attrs.inverse = true
526
+ when 5, 6
527
+ @attrs.blink = true
528
+ when 8
529
+ @attrs.concealed = true
530
+ when 9
531
+ @attrs.strikethrough = true
532
+ when 22
533
+ @attrs.bold = false
534
+ @attrs.faint = false
535
+ when 23
536
+ @attrs.italic = false
537
+ when 24
538
+ @attrs.underline = false
539
+ when 27
540
+ @attrs.inverse = false
541
+ when 25
542
+ @attrs.blink = false
543
+ when 28
544
+ @attrs.concealed = false
545
+ when 29
546
+ @attrs.strikethrough = false
547
+ when 30..37
548
+ @attrs.fg = p - 30
549
+ when 38
550
+ if params[i + 1] == 2 && params[i + 2] && params[i + 3] && params[i + 4]
551
+ @attrs.fg = [params[i + 2], params[i + 3], params[i + 4]]
552
+ i += 4
553
+ elsif params[i + 1] == 5 && params[i + 2]
554
+ @attrs.fg = params[i + 2]
555
+ i += 2
556
+ end
557
+ when 39
558
+ @attrs.fg = nil
559
+ when 40..47
560
+ @attrs.bg = p - 40
561
+ when 48
562
+ if params[i + 1] == 2 && params[i + 2] && params[i + 3] && params[i + 4]
563
+ @attrs.bg = [params[i + 2], params[i + 3], params[i + 4]]
564
+ i += 4
565
+ elsif params[i + 1] == 5 && params[i + 2]
566
+ @attrs.bg = params[i + 2]
567
+ i += 2
568
+ end
569
+ when 49
570
+ @attrs.bg = nil
571
+ when 90..97
572
+ @attrs.fg = p - 90 + 8
573
+ when 100..107
574
+ @attrs.bg = p - 100 + 8
575
+ end
576
+ i += 1
577
+ end
578
+ end
579
+
580
+ def apply_sgr_subparams(sub)
581
+ case sub[0]
582
+ when 4
583
+ # Underline style: 4:0=off, 4:1=single, 4:2=double, 4:3=curly, 4:4=dotted, 4:5=dashed
584
+ style = sub[1] || 1
585
+ if style == 0
586
+ @attrs.underline = false
587
+ else
588
+ @attrs.underline = style
589
+ end
590
+ when 38
591
+ # Foreground color with sub-parameters
592
+ if sub[1] == 2
593
+ # 38:2:cs:R:G:B or 38:2:R:G:B (cs = color space, often empty/omitted)
594
+ r, g, b = extract_rgb_subparams(sub, 2)
595
+ @attrs.fg = [r, g, b] if r && g && b
596
+ elsif sub[1] == 5 && sub[2]
597
+ @attrs.fg = sub[2]
598
+ end
599
+ when 48
600
+ # Background color with sub-parameters
601
+ if sub[1] == 2
602
+ r, g, b = extract_rgb_subparams(sub, 2)
603
+ @attrs.bg = [r, g, b] if r && g && b
604
+ elsif sub[1] == 5 && sub[2]
605
+ @attrs.bg = sub[2]
606
+ end
607
+ when 58
608
+ # Underline color
609
+ if sub[1] == 2
610
+ r, g, b = extract_rgb_subparams(sub, 2)
611
+ @attrs.underline_color = [r, g, b] if r && g && b
612
+ elsif sub[1] == 5 && sub[2]
613
+ @attrs.underline_color = sub[2]
614
+ end
615
+ when 59
616
+ @attrs.underline_color = nil
617
+ end
618
+ end
619
+
620
+ def save_cursor
621
+ saved_attrs = Cell.new
622
+ saved_attrs.copy_from(@attrs)
623
+ @saved_cursor = {
624
+ row: @cursor.row, col: @cursor.col,
625
+ attrs: saved_attrs,
626
+ origin_mode: @origin_mode,
627
+ auto_wrap: @auto_wrap,
628
+ charset_g0: @charset_g0,
629
+ charset_g1: @charset_g1,
630
+ active_charset: @active_charset,
631
+ pending_wrap: @pending_wrap,
632
+ }
633
+ end
634
+
635
+ def restore_cursor
636
+ if @saved_cursor
637
+ @cursor.row = @saved_cursor[:row]
638
+ @cursor.col = @saved_cursor[:col]
639
+ @attrs.copy_from(@saved_cursor[:attrs])
640
+ @origin_mode = @saved_cursor[:origin_mode]
641
+ @auto_wrap = @saved_cursor[:auto_wrap]
642
+ @charset_g0 = @saved_cursor[:charset_g0]
643
+ @charset_g1 = @saved_cursor[:charset_g1]
644
+ @active_charset = @saved_cursor[:active_charset]
645
+ @pending_wrap = @saved_cursor[:pending_wrap] || false
646
+ end
647
+ end
648
+
649
+ def application_cursor_keys?
650
+ @application_cursor_keys
651
+ end
652
+
653
+ def application_cursor_keys=(val)
654
+ @application_cursor_keys = val
655
+ end
656
+
657
+ def bracketed_paste_mode?
658
+ @bracketed_paste_mode
659
+ end
660
+
661
+ def bracketed_paste_mode=(val)
662
+ @bracketed_paste_mode = val
663
+ end
664
+
665
+ def focus_reporting?
666
+ @focus_reporting
667
+ end
668
+
669
+ def focus_reporting=(val)
670
+ @focus_reporting = val
671
+ end
672
+
673
+ def auto_wrap?
674
+ @auto_wrap
675
+ end
676
+
677
+ def auto_wrap=(val)
678
+ @auto_wrap = val
679
+ @pending_wrap = false
680
+ end
681
+
682
+ attr_accessor :mouse_tracking, :mouse_encoding, :insert_mode, :active_charset, :application_keypad, :cursor_style, :bell, :single_shift
683
+
684
+ def push_title
685
+ @title_stack.push(@title)
686
+ end
687
+
688
+ def pop_title
689
+ @title = @title_stack.pop if @title_stack.any?
690
+ end
691
+
692
+ def mark_dirty(row)
693
+ @dirty_rows << row
694
+ end
695
+
696
+ def mark_all_dirty
697
+ @dirty_rows = Set.new((0...@rows).to_a)
698
+ end
699
+
700
+ def clear_dirty
701
+ @dirty_rows = Set.new
702
+ end
703
+
704
+ def set_hyperlink(uri)
705
+ @attrs.hyperlink = uri
706
+ end
707
+
708
+ # Write a sequence of styled prompt segments directly into the
709
+ # cell grid, bypassing the ANSI SGR parser. Each segment is a
710
+ # `{text:, fg:, bg:, bold:, italic:, underline:, inverse:}` Hash
711
+ # (see `Rubish::REPL#prompt_segments`). Color values follow
712
+ # rubish's encoding: nil = default, 0..255 = palette index,
713
+ # `[:rgb, r, g, b]` = true color (translated to `[r, g, b]` for
714
+ # this Screen's storage convention).
715
+ #
716
+ # Existing `@attrs` is snapshotted and restored so any in-flight
717
+ # SGR state from prior parser-driven rendering is preserved.
718
+ def put_styled_segments(segments)
719
+ saved_fg = @attrs.fg
720
+ saved_bg = @attrs.bg
721
+ saved_bold = @attrs.bold
722
+ saved_italic = @attrs.italic
723
+ saved_underline = @attrs.underline
724
+ saved_inverse = @attrs.inverse
725
+ begin
726
+ segments.each do |seg|
727
+ @attrs.fg = translate_segment_color(seg[:fg])
728
+ @attrs.bg = translate_segment_color(seg[:bg])
729
+ @attrs.bold = !!seg[:bold]
730
+ @attrs.italic = !!seg[:italic]
731
+ @attrs.underline = !!seg[:underline]
732
+ @attrs.inverse = !!seg[:inverse]
733
+ (seg[:text] || '').each_char { |c| put_char(c) }
734
+ end
735
+ ensure
736
+ @attrs.fg = saved_fg
737
+ @attrs.bg = saved_bg
738
+ @attrs.bold = saved_bold
739
+ @attrs.italic = saved_italic
740
+ @attrs.underline = saved_underline
741
+ @attrs.inverse = saved_inverse
742
+ end
743
+ end
744
+
745
+ private def translate_segment_color(color)
746
+ case color
747
+ when nil then nil
748
+ when Integer then color
749
+ when Array
750
+ # rubish exposes true color as [:rgb, r, g, b]; this Screen
751
+ # stores it as [r, g, b].
752
+ color.first == :rgb ? color[1..3] : color
753
+ end
754
+ end
755
+ public
756
+
757
+ # OSC 133 prompt boundary marker. `kind` is one of:
758
+ # :prompt_start — OSC 133 ; A — beginning of a fresh prompt block
759
+ # :prompt_end — OSC 133 ; B — end of prompt / start of input
760
+ # :command_start — OSC 133 ; C — start of command output
761
+ # :command_end — OSC 133 ; D — end of command output (with optional exit code)
762
+ #
763
+ # Marks are stored as visual rows (scrollback rows + grid rows from 0).
764
+ # `:prompt_start` opens a new mark; subsequent kinds populate it.
765
+ def osc133_mark(kind, exit_code: nil)
766
+ row = @scrollback.size + @cursor.row
767
+ case kind
768
+ when :prompt_start
769
+ @current_command_mark = {
770
+ prompt_start: row, input_start: nil,
771
+ output_start: nil, output_end: nil, exit_code: nil,
772
+ }
773
+ @command_marks << @current_command_mark
774
+ when :prompt_end
775
+ @current_command_mark ||= {prompt_start: row, input_start: nil,
776
+ output_start: nil, output_end: nil, exit_code: nil}
777
+ @command_marks << @current_command_mark unless @command_marks.last.equal?(@current_command_mark)
778
+ @current_command_mark[:input_start] = row
779
+ when :command_start
780
+ return unless @current_command_mark
781
+ @current_command_mark[:output_start] = row
782
+ when :command_end
783
+ return unless @current_command_mark
784
+ @current_command_mark[:output_end] = row
785
+ @current_command_mark[:exit_code] = exit_code
786
+ end
787
+ end
788
+
789
+ # Extract the visible text of a command's output region. `mark` is
790
+ # one of the entries from `@command_marks`. Rows that have scrolled
791
+ # off the front of the scrollback are silently skipped — the text
792
+ # is no longer recoverable. Returns "" when the mark is incomplete
793
+ # (no :output_start or :output_end yet).
794
+ def text_for_command_output(mark)
795
+ return '' unless mark && mark[:output_start] && mark[:output_end]
796
+ from = mark[:output_start]
797
+ to = mark[:output_end]
798
+ return '' if to <= from
799
+ sb_size = @scrollback.size
800
+ lines = []
801
+ (from...to).each do |abs_row|
802
+ row = abs_row < 0 ? nil :
803
+ abs_row < sb_size ? @scrollback[abs_row] :
804
+ @grid[abs_row - sb_size]
805
+ next unless row
806
+ lines << row.map { |c| c.char || ' ' }.join.rstrip
807
+ end
808
+ lines.join("\n")
809
+ end
810
+
811
+ # Most recently completed command mark (D was emitted). nil if no
812
+ # command has finished yet on this pane.
813
+ def last_completed_command_mark
814
+ @command_marks.reverse_each.find { |m| m[:output_end] }
815
+ end
816
+
817
+ # Attach the literal command text to the most recently opened mark.
818
+ # The host calls this at submit time (between OSC 133 ;B and ;C)
819
+ # so click-to-rerun can recover the command text from a clicked
820
+ # prompt row long after submission.
821
+ def set_current_command_text(text)
822
+ return unless @current_command_mark
823
+ @current_command_mark[:command_text] = text
824
+ end
825
+
826
+ # Find the command mark whose prompt+input region covers `abs_row`,
827
+ # or nil if no mark covers that row. The region runs from
828
+ # :prompt_start (inclusive) up to :output_start (exclusive). If a
829
+ # command is still running and no :output_start has been recorded
830
+ # yet, the region is taken as just :prompt_start itself (one row).
831
+ def find_command_mark_at_row(abs_row)
832
+ @command_marks.reverse_each.find do |m|
833
+ next false unless m[:prompt_start]
834
+ upper = m[:output_start] || (m[:prompt_start] + 1)
835
+ abs_row >= m[:prompt_start] && abs_row < upper
836
+ end
837
+ end
838
+
839
+ # Find the command mark whose *output* region covers `abs_row` and
840
+ # return [start_row, end_row] (both inclusive) so callers like the
841
+ # GUI's triple-click can highlight or copy that whole region. nil
842
+ # if no completed mark covers the row.
843
+ def output_region_for_row(abs_row)
844
+ mark = @command_marks.reverse_each.find do |m|
845
+ next false unless m[:output_start] && m[:output_end]
846
+ abs_row >= m[:output_start] && abs_row < m[:output_end]
847
+ end
848
+ return nil unless mark
849
+ [mark[:output_start], mark[:output_end] - 1]
850
+ end
851
+
852
+ # When scrollback shifts (oldest row dropped), every row index in
853
+ # @command_marks moves by `delta` (typically -1). Marks that would
854
+ # now point before the scrollback floor are dropped — their content
855
+ # is no longer reachable.
856
+ def adjust_command_marks(delta)
857
+ return if @command_marks.empty?
858
+ @command_marks.each do |m|
859
+ m.each_key do |k|
860
+ next if k == :exit_code
861
+ v = m[k]
862
+ m[k] = v + delta if v
863
+ end
864
+ end
865
+ @command_marks.reject! { |m| m[:prompt_start] && m[:prompt_start] < 0 }
866
+ if @current_command_mark && (@current_command_mark[:prompt_start] || 0) < 0
867
+ @current_command_mark = nil
868
+ end
869
+ end
870
+
871
+ attr_accessor :clipboard_handler, :palette_handler, :glyph_measurer
872
+
873
+ def set_clipboard(text)
874
+ @clipboard_handler&.call(:set, text)
875
+ end
876
+
877
+ def clipboard_content
878
+ @clipboard_handler&.call(:get, nil)
879
+ end
880
+
881
+ def designate_charset(g, charset)
882
+ case g
883
+ when 0 then @charset_g0 = charset
884
+ when 1 then @charset_g1 = charset
885
+ when 2 then @charset_g2 = charset
886
+ when 3 then @charset_g3 = charset
887
+ end
888
+ end
889
+
890
+ def origin_mode?
891
+ @origin_mode
892
+ end
893
+
894
+ def origin_mode=(val)
895
+ @origin_mode = val
896
+ @pending_wrap = false
897
+ if val
898
+ @cursor.row = @scroll_top
899
+ @cursor.col = 0
900
+ end
901
+ end
902
+
903
+ def using_alt_screen?
904
+ @using_alt_screen
905
+ end
906
+
907
+ def switch_to_alt_screen
908
+ return if @using_alt_screen
909
+
910
+ @main_grid = @grid
911
+ @main_line_wrapped = @line_wrapped
912
+ @main_cursor = [@cursor.row, @cursor.col, @cursor.visible]
913
+ @main_scroll_top = @scroll_top
914
+ @main_scroll_bottom = @scroll_bottom
915
+ @main_saved_cursor = @saved_cursor
916
+ @main_scrollback = @scrollback
917
+ @main_scrollback_wrapped = @scrollback_wrapped
918
+ @main_rows = @rows
919
+ @main_cols = @cols
920
+
921
+ @grid = Array.new(@rows) { Array.new(@cols) { Cell.new } }
922
+ @line_wrapped = Array.new(@rows, false)
923
+ @cursor = Cursor.new
924
+ @attrs = Cell.new
925
+ @scroll_top = 0
926
+ @scroll_bottom = @rows - 1
927
+ @saved_cursor = nil
928
+ @scrollback = []
929
+ @scrollback_wrapped = []
930
+ @pending_wrap = false
931
+ @using_alt_screen = true
932
+ mark_all_dirty
933
+ end
934
+
935
+ def switch_to_main_screen
936
+ return unless @using_alt_screen
937
+
938
+ current_rows = @rows
939
+ current_cols = @cols
940
+
941
+ @grid = @main_grid
942
+ @line_wrapped = @main_line_wrapped
943
+ @cursor = Cursor.new
944
+ @cursor.row, @cursor.col, @cursor.visible = @main_cursor
945
+ @scroll_top = @main_scroll_top
946
+ @scroll_bottom = @main_scroll_bottom
947
+ @saved_cursor = @main_saved_cursor
948
+ @scrollback = @main_scrollback
949
+ @scrollback_wrapped = @main_scrollback_wrapped
950
+ @rows = @main_rows
951
+ @cols = @main_cols
952
+ @attrs = Cell.new
953
+
954
+ @main_grid = nil
955
+ @main_line_wrapped = nil
956
+ @main_cursor = nil
957
+ @main_scroll_top = nil
958
+ @main_scroll_bottom = nil
959
+ @main_saved_cursor = nil
960
+ @main_scrollback = nil
961
+ @main_scrollback_wrapped = nil
962
+ @main_rows = nil
963
+ @main_cols = nil
964
+ @pending_wrap = false
965
+ @using_alt_screen = false
966
+
967
+ # If terminal was resized while in alt screen, adjust the restored main grid
968
+ if current_rows != @rows || current_cols != @cols
969
+ resize(current_rows, current_cols)
970
+ end
971
+
972
+ mark_all_dirty
973
+ end
974
+
975
+ def show_cursor
976
+ @cursor.visible = true
977
+ end
978
+
979
+ def hide_cursor
980
+ @cursor.visible = false
981
+ end
982
+
983
+ def to_text
984
+ @grid.map { |row| row.map { |cell| cell.char }.join.rstrip }.join("\n").rstrip
985
+ end
986
+
987
+ def selected_text(sr, sc, er, ec)
988
+ lines = []
989
+ (sr..er).each do |r|
990
+ from = (r == sr) ? sc : 0
991
+ to = (r == er) ? ec : @cols - 1
992
+ lines << @grid[r][from..to].map { |cell| cell.char }.join.rstrip
993
+ end
994
+ lines.join("\n")
995
+ end
996
+
997
+ def word_boundaries_at(row, col)
998
+ return nil if row < 0 || row >= @rows || col < 0 || col >= @cols
999
+
1000
+ line = @grid[row]
1001
+ cls = char_class(line[col].char)
1002
+
1003
+ start_col = col
1004
+ start_col -= 1 while start_col > 0 && char_class(line[start_col - 1].char) == cls
1005
+
1006
+ end_col = col
1007
+ end_col += 1 while end_col < @cols - 1 && char_class(line[end_col + 1].char) == cls
1008
+
1009
+ [start_col, end_col]
1010
+ end
1011
+
1012
+ def soft_reset
1013
+ @attrs = Cell.new
1014
+ @cursor.visible = true
1015
+ @saved_cursor = nil
1016
+ @origin_mode = false
1017
+ @auto_wrap = true
1018
+ @insert_mode = false
1019
+ @application_cursor_keys = false
1020
+ @bracketed_paste_mode = false
1021
+ @focus_reporting = false
1022
+ @charset_g0 = :ascii
1023
+ @charset_g1 = :ascii
1024
+ @charset_g2 = :ascii
1025
+ @charset_g3 = :ascii
1026
+ @active_charset = 0
1027
+ @single_shift = nil
1028
+ @cursor_style = 0
1029
+ @tab_stops = default_tab_stops
1030
+ @scroll_top = 0
1031
+ @scroll_bottom = @rows - 1
1032
+ @pending_wrap = false
1033
+ end
1034
+
1035
+ def decaln
1036
+ @grid.each do |row|
1037
+ row.each do |cell|
1038
+ cell.reset!
1039
+ cell.char = 'E'
1040
+ end
1041
+ end
1042
+ @cursor.row = 0
1043
+ @cursor.col = 0
1044
+ @pending_wrap = false
1045
+ mark_all_dirty
1046
+ end
1047
+
1048
+ def reset
1049
+ @cursor = Cursor.new
1050
+ @attrs = Cell.new
1051
+ @grid = Array.new(@rows) { Array.new(@cols) { Cell.new } }
1052
+ @line_wrapped = Array.new(@rows, false)
1053
+ @scroll_top = 0
1054
+ @scroll_bottom = @rows - 1
1055
+ @saved_cursor = nil
1056
+ @scrollback = []
1057
+ @scrollback_wrapped = []
1058
+ @tab_stops = default_tab_stops
1059
+ @pending_wrap = false
1060
+ mark_all_dirty
1061
+ end
1062
+
1063
+ def resize(new_rows, new_cols)
1064
+ old_cols = @cols
1065
+ @rows = new_rows
1066
+ @cols = new_cols
1067
+
1068
+ if new_cols != old_cols
1069
+ reflow(new_rows, new_cols, old_cols)
1070
+ else
1071
+ # Only row count changed — simple add/remove
1072
+ if new_rows > @grid.size
1073
+ (new_rows - @grid.size).times do
1074
+ @grid.push(Array.new(new_cols) { Cell.new })
1075
+ @line_wrapped.push(false)
1076
+ end
1077
+ elsif new_rows < @grid.size
1078
+ @grid.slice!(new_rows..)
1079
+ @line_wrapped.slice!(new_rows..)
1080
+ end
1081
+ end
1082
+
1083
+ @scroll_top = 0
1084
+ @scroll_bottom = new_rows - 1
1085
+ @cursor.row = clamp_row(@cursor.row)
1086
+ @cursor.col = clamp_col(@cursor.col)
1087
+ @pending_wrap = false
1088
+ end
1089
+
1090
+ private
1091
+
1092
+ # Extract R, G, B from sub-params like [38, 2, cs, R, G, B] or [38, 2, R, G, B]
1093
+ # The color space ID (cs) may be nil/empty, so we try both layouts.
1094
+ def extract_rgb_subparams(sub, type_idx)
1095
+ # sub[type_idx] is the type (2 or 5)
1096
+ # Try [_, 2, cs, R, G, B] first (6 elements), then [_, 2, R, G, B] (5 elements)
1097
+ if sub.length >= type_idx + 5 && sub[type_idx + 2] && sub[type_idx + 3] && sub[type_idx + 4]
1098
+ if sub[type_idx + 1].nil?
1099
+ # Color space ID is empty/nil: [38, 2, nil, R, G, B]
1100
+ [sub[type_idx + 2], sub[type_idx + 3], sub[type_idx + 4]]
1101
+ else
1102
+ # No color space ID: [38, 2, R, G, B]
1103
+ [sub[type_idx + 1], sub[type_idx + 2], sub[type_idx + 3]]
1104
+ end
1105
+ elsif sub.length >= type_idx + 4 && sub[type_idx + 1] && sub[type_idx + 2] && sub[type_idx + 3]
1106
+ [sub[type_idx + 1], sub[type_idx + 2], sub[type_idx + 3]]
1107
+ else
1108
+ [nil, nil, nil]
1109
+ end
1110
+ end
1111
+
1112
+ def reflow(new_rows, new_cols, old_cols)
1113
+ # Convert cursor to absolute position (scrollback + grid row index)
1114
+ cursor_abs = @scrollback.size + @cursor.row
1115
+
1116
+ # Merge scrollback and grid into logical lines
1117
+ all_rows = @scrollback + @grid
1118
+ all_wrapped = @scrollback_wrapped + @line_wrapped
1119
+
1120
+ logical_lines = []
1121
+ i = 0
1122
+ while i < all_rows.size
1123
+ line = all_rows[i].dup
1124
+ while i < all_rows.size - 1 && all_wrapped[i]
1125
+ i += 1
1126
+ line.concat(all_rows[i])
1127
+ end
1128
+ logical_lines << line
1129
+ i += 1
1130
+ end
1131
+
1132
+ # Re-wrap logical lines to new width
1133
+ new_all_rows = []
1134
+ new_all_wrapped = []
1135
+ cursor_new_abs = nil
1136
+
1137
+ # Track cursor: find which logical line row cursor_abs falls in
1138
+ logical_row_start = 0
1139
+ logical_lines.each do |line|
1140
+ # Count how many original rows this logical line spanned
1141
+ span = 1
1142
+ temp = logical_row_start
1143
+ while temp < all_wrapped.size - 1 && all_wrapped[temp]
1144
+ span += 1
1145
+ temp += 1
1146
+ end
1147
+
1148
+ # Strip trailing blank cells
1149
+ content_len = line.size
1150
+ while content_len > 0 && line[content_len - 1].char == ' ' && line[content_len - 1].fg.nil? && line[content_len - 1].bg.nil? && !line[content_len - 1].bold && !line[content_len - 1].underline && !line[content_len - 1].inverse
1151
+ content_len -= 1
1152
+ end
1153
+
1154
+ if content_len == 0
1155
+ new_all_rows << Array.new(new_cols) { Cell.new }
1156
+ new_all_wrapped << false
1157
+ if cursor_abs >= logical_row_start && cursor_abs < logical_row_start + span
1158
+ cursor_new_abs = new_all_rows.size - 1
1159
+ end
1160
+ else
1161
+ col = 0
1162
+ row_cells = []
1163
+ line[0...content_len].each do |cell|
1164
+ if col + [cell.width, 1].max > new_cols
1165
+ # Pad remaining
1166
+ while row_cells.size < new_cols
1167
+ row_cells << Cell.new
1168
+ end
1169
+ new_all_rows << row_cells
1170
+ new_all_wrapped << true
1171
+ row_cells = []
1172
+ col = 0
1173
+ end
1174
+ row_cells << cell
1175
+ col += [cell.width, 1].max
1176
+ end
1177
+ # Pad last row
1178
+ while row_cells.size < new_cols
1179
+ row_cells << Cell.new
1180
+ end
1181
+ new_all_rows << row_cells
1182
+ new_all_wrapped << false
1183
+
1184
+ if cursor_abs >= logical_row_start && cursor_abs < logical_row_start + span && cursor_new_abs.nil?
1185
+ # Place cursor on the last physical row of this logical line
1186
+ cursor_new_abs = new_all_rows.size - 1
1187
+ end
1188
+ end
1189
+
1190
+ logical_row_start += span
1191
+ end
1192
+
1193
+ cursor_new_abs ||= [new_all_rows.size - 1, 0].max
1194
+
1195
+ # Split into scrollback and visible grid
1196
+ # The visible grid should have new_rows rows; excess goes to scrollback
1197
+ if new_all_rows.size <= new_rows
1198
+ @scrollback = []
1199
+ @scrollback_wrapped = []
1200
+ @grid = new_all_rows
1201
+ @line_wrapped = new_all_wrapped
1202
+ # Pad to fill screen
1203
+ while @grid.size < new_rows
1204
+ @grid.push(Array.new(new_cols) { Cell.new })
1205
+ @line_wrapped.push(false)
1206
+ end
1207
+ @cursor.row = [cursor_new_abs, new_rows - 1].min
1208
+ else
1209
+ split = new_all_rows.size - new_rows
1210
+ # Ensure cursor is visible
1211
+ if cursor_new_abs < split
1212
+ split = cursor_new_abs
1213
+ end
1214
+ @scrollback = new_all_rows[0...split]
1215
+ @scrollback_wrapped = new_all_wrapped[0...split]
1216
+ @grid = new_all_rows[split..]
1217
+ @line_wrapped = new_all_wrapped[split..]
1218
+ # Trim or pad grid to exactly new_rows
1219
+ while @grid.size < new_rows
1220
+ @grid.push(Array.new(new_cols) { Cell.new })
1221
+ @line_wrapped.push(false)
1222
+ end
1223
+ if @grid.size > new_rows
1224
+ # Push excess to scrollback
1225
+ excess = @grid.size - new_rows
1226
+ @scrollback.concat(@grid.slice!(0, excess))
1227
+ @scrollback_wrapped.concat(@line_wrapped.slice!(0, excess))
1228
+ end
1229
+ @cursor.row = cursor_new_abs - split
1230
+ @cursor.row = @cursor.row.clamp(0, new_rows - 1)
1231
+ end
1232
+
1233
+ # Trim scrollback to limit
1234
+ while @scrollback.size > self.class.scrollback_limit
1235
+ @scrollback.shift
1236
+ @scrollback_wrapped.shift
1237
+ end
1238
+
1239
+ @cursor.col = [@cursor.col, new_cols - 1].min
1240
+ end
1241
+
1242
+ def default_tab_stops
1243
+ (8...@cols).step(8).to_a
1244
+ end
1245
+
1246
+ def clamp_row(row)
1247
+ [[row, 0].max, @rows - 1].min
1248
+ end
1249
+
1250
+ def clamp_col(col)
1251
+ [[col, 0].max, @cols - 1].min
1252
+ end
1253
+
1254
+ def clear_row(r)
1255
+ @grid[r].each(&:reset!)
1256
+ end
1257
+
1258
+ def char_class(c)
1259
+ if c =~ /\s/
1260
+ :space
1261
+ elsif c =~ /\w/
1262
+ :word
1263
+ else
1264
+ :other
1265
+ end
1266
+ end
1267
+
1268
+ def place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family = nil)
1269
+ # Discard if block is larger than screen
1270
+ return if mc_cols > @cols || mc_rows > @rows
1271
+
1272
+ # Wrap if it doesn't fit on current line
1273
+ if @cursor.col + mc_cols > @cols
1274
+ @cursor.col = 0
1275
+ line_feed
1276
+ end
1277
+
1278
+ # Scroll if block doesn't fit vertically from cursor
1279
+ while @cursor.row + mc_rows > @rows
1280
+ scroll_up(1)
1281
+ @cursor.row = [@cursor.row - 1, 0].max
1282
+ end
1283
+
1284
+ anchor_row = @cursor.row
1285
+ anchor_col = @cursor.col
1286
+
1287
+ # Erase any existing multicells in the block area
1288
+ mc_rows.times do |dr|
1289
+ mc_cols.times do |dc|
1290
+ erase_multicell_at(anchor_row + dr, anchor_col + dc)
1291
+ end
1292
+ end
1293
+
1294
+ # Set anchor cell
1295
+ anchor = @grid[anchor_row][anchor_col]
1296
+ anchor.copy_from(@attrs)
1297
+ anchor.char = text
1298
+ anchor.width = 1
1299
+ anchor.multicell = {
1300
+ cols: mc_cols, rows: mc_rows, scale: scale,
1301
+ frac_n: frac_n, frac_d: frac_d, valign: valign, halign: halign,
1302
+ family: family
1303
+ }
1304
+
1305
+ # Mark continuation cells
1306
+ mc_rows.times do |dr|
1307
+ mc_cols.times do |dc|
1308
+ next if dr == 0 && dc == 0
1309
+ cont = @grid[anchor_row + dr][anchor_col + dc]
1310
+ cont.reset!
1311
+ cont.multicell = :cont
1312
+ end
1313
+ end
1314
+
1315
+ @cursor.col += mc_cols
1316
+ end
1317
+
1318
+ def erase_multicell_at(row, col)
1319
+ cell = @grid[row][col]
1320
+ return unless cell.multicell
1321
+
1322
+ if cell.multicell.is_a?(Hash)
1323
+ # This is the anchor — erase the whole block
1324
+ mc = cell.multicell
1325
+ mc[:rows].times do |dr|
1326
+ mc[:cols].times do |dc|
1327
+ @grid[row + dr][col + dc].reset!
1328
+ end
1329
+ end
1330
+ elsif cell.multicell == :cont
1331
+ # Find the anchor by scanning up and left
1332
+ find_multicell_anchor(row, col)&.then do |ar, ac|
1333
+ erase_multicell_at(ar, ac)
1334
+ end
1335
+ end
1336
+ end
1337
+
1338
+ def find_multicell_anchor(row, col)
1339
+ # Scan backwards to find the anchor cell
1340
+ (row).downto(0) do |r|
1341
+ start_col = (r == row) ? col : @cols - 1
1342
+ start_col.downto(0) do |c|
1343
+ cell = @grid[r][c]
1344
+ if cell.multicell.is_a?(Hash)
1345
+ mc = cell.multicell
1346
+ # Check if (row, col) falls within this anchor's block
1347
+ if row < r + mc[:rows] && col >= c && col < c + mc[:cols]
1348
+ return [r, c]
1349
+ end
1350
+ end
1351
+ end
1352
+ end
1353
+ nil
1354
+ end
1355
+
1356
+ def combining?(c)
1357
+ cp = c.ord
1358
+ return false if cp < 0x0300
1359
+ (cp >= 0x0300 && cp <= 0x036F) || # Combining Diacritical Marks
1360
+ (cp >= 0x0483 && cp <= 0x0489) || # Cyrillic combining marks
1361
+ (cp >= 0x0591 && cp <= 0x05BD) || # Hebrew combining marks
1362
+ cp == 0x05BF ||
1363
+ (cp >= 0x05C1 && cp <= 0x05C2) ||
1364
+ (cp >= 0x05C4 && cp <= 0x05C5) ||
1365
+ cp == 0x05C7 ||
1366
+ (cp >= 0x0610 && cp <= 0x061A) || # Arabic combining marks
1367
+ (cp >= 0x064B && cp <= 0x065F) ||
1368
+ cp == 0x0670 ||
1369
+ (cp >= 0x06D6 && cp <= 0x06DC) ||
1370
+ (cp >= 0x06DF && cp <= 0x06E4) ||
1371
+ (cp >= 0x06E7 && cp <= 0x06E8) ||
1372
+ (cp >= 0x06EA && cp <= 0x06ED) ||
1373
+ cp == 0x0711 ||
1374
+ (cp >= 0x0730 && cp <= 0x074A) || # Syriac
1375
+ (cp >= 0x07A6 && cp <= 0x07B0) || # Thaana
1376
+ (cp >= 0x0816 && cp <= 0x0819) || # Samaritan
1377
+ (cp >= 0x081B && cp <= 0x0823) ||
1378
+ (cp >= 0x0825 && cp <= 0x0827) ||
1379
+ (cp >= 0x0829 && cp <= 0x082D) ||
1380
+ (cp >= 0x0859 && cp <= 0x085B) || # Mandaic
1381
+ (cp >= 0x0900 && cp <= 0x0903) || # Devanagari
1382
+ (cp >= 0x093A && cp <= 0x094F) ||
1383
+ (cp >= 0x0951 && cp <= 0x0957) ||
1384
+ (cp >= 0x0962 && cp <= 0x0963) ||
1385
+ (cp >= 0x0981 && cp <= 0x0983) || # Bengali
1386
+ cp == 0x09BC || cp == 0x09CD ||
1387
+ (cp >= 0x09BE && cp <= 0x09C4) ||
1388
+ (cp >= 0x0A01 && cp <= 0x0A03) || # Gurmukhi
1389
+ (cp >= 0x0A3C && cp <= 0x0A51) ||
1390
+ (cp >= 0x0B01 && cp <= 0x0B03) || # Oriya
1391
+ (cp >= 0x0B3C && cp <= 0x0B57) ||
1392
+ (cp >= 0x0BBE && cp <= 0x0BCD) || # Tamil
1393
+ (cp >= 0x0C00 && cp <= 0x0C04) || # Telugu
1394
+ (cp >= 0x0C3E && cp <= 0x0C56) ||
1395
+ (cp >= 0x0C81 && cp <= 0x0C83) || # Kannada
1396
+ (cp >= 0x0CBC && cp <= 0x0CD6) ||
1397
+ (cp >= 0x0D00 && cp <= 0x0D03) || # Malayalam
1398
+ (cp >= 0x0D3B && cp <= 0x0D4D) ||
1399
+ (cp >= 0x0D57 && cp <= 0x0D57) ||
1400
+ (cp >= 0x0DCA && cp <= 0x0DDF) || # Sinhala
1401
+ (cp >= 0x0E31 && cp <= 0x0E3A) || # Thai
1402
+ (cp >= 0x0E47 && cp <= 0x0E4E) ||
1403
+ (cp >= 0x0EB1 && cp <= 0x0EBC) || # Lao
1404
+ (cp >= 0x0EC8 && cp <= 0x0ECD) ||
1405
+ (cp >= 0x0F18 && cp <= 0x0F19) || # Tibetan
1406
+ cp == 0x0F35 || cp == 0x0F37 || cp == 0x0F39 ||
1407
+ (cp >= 0x0F3E && cp <= 0x0F3F) ||
1408
+ (cp >= 0x0F71 && cp <= 0x0F84) ||
1409
+ (cp >= 0x0F86 && cp <= 0x0F87) ||
1410
+ (cp >= 0x0F8D && cp <= 0x0FBC) ||
1411
+ cp == 0x0FC6 ||
1412
+ (cp >= 0x1000 && cp <= 0x1059) && c =~ /\p{M}/ || # Myanmar (selective)
1413
+ (cp >= 0x135D && cp <= 0x135F) || # Ethiopic
1414
+ (cp >= 0x1712 && cp <= 0x1714) || # Tagalog
1415
+ (cp >= 0x1732 && cp <= 0x1734) || # Hanunoo
1416
+ (cp >= 0x17B4 && cp <= 0x17D3) || # Khmer
1417
+ cp == 0x17DD ||
1418
+ (cp >= 0x180B && cp <= 0x180D) || # Mongolian
1419
+ cp == 0x180F ||
1420
+ (cp >= 0x1885 && cp <= 0x1886) ||
1421
+ cp == 0x18A9 ||
1422
+ (cp >= 0x1920 && cp <= 0x193B) || # Limbu/Tai Le
1423
+ (cp >= 0x1A17 && cp <= 0x1A1B) || # Buginese
1424
+ (cp >= 0x1A55 && cp <= 0x1A7F) || # Tai Tham
1425
+ (cp >= 0x1AB0 && cp <= 0x1ACE) || # Combining Diacritical Marks Extended
1426
+ (cp >= 0x1B00 && cp <= 0x1B04) || # Balinese
1427
+ (cp >= 0x1B34 && cp <= 0x1B44) ||
1428
+ (cp >= 0x1B6B && cp <= 0x1B73) ||
1429
+ (cp >= 0x1B80 && cp <= 0x1B82) || # Sundanese
1430
+ (cp >= 0x1BA1 && cp <= 0x1BAD) ||
1431
+ (cp >= 0x1BE6 && cp <= 0x1BF3) || # Batak
1432
+ (cp >= 0x1C24 && cp <= 0x1C37) || # Lepcha
1433
+ (cp >= 0x1CD0 && cp <= 0x1CF9) || # Vedic Extensions
1434
+ (cp >= 0x1DC0 && cp <= 0x1DFF) || # Combining Diacritical Marks Supplement
1435
+ (cp >= 0x20D0 && cp <= 0x20FF) || # Combining Diacritical Marks for Symbols
1436
+ (cp >= 0xFE00 && cp <= 0xFE0F) || # Variation Selectors
1437
+ (cp >= 0xFE20 && cp <= 0xFE2F) || # Combining Half Marks
1438
+ (cp >= 0x101FD && cp <= 0x101FD) || # Phaistos Disc
1439
+ (cp >= 0x102E0 && cp <= 0x102E0) ||
1440
+ (cp >= 0x10376 && cp <= 0x1037A) ||
1441
+ (cp >= 0x10A01 && cp <= 0x10A0F) ||
1442
+ (cp >= 0x10A38 && cp <= 0x10A3F) ||
1443
+ (cp >= 0x11000 && cp <= 0x1104D) && c =~ /\p{M}/ || # Brahmi etc (selective)
1444
+ (cp >= 0x1D165 && cp <= 0x1D1AD) || # Musical Symbols combining
1445
+ (cp >= 0x1D242 && cp <= 0x1D244) ||
1446
+ (cp >= 0xE0100 && cp <= 0xE01EF) # Variation Selectors Supplement
1447
+ end
1448
+
1449
+ def char_width(c)
1450
+ cp = c.ord
1451
+ return 2 if (cp >= 0x1100 && cp <= 0x115F) || # Hangul Jamo
1452
+ cp == 0x2329 || cp == 0x232A || # angle brackets
1453
+ (cp >= 0x2E80 && cp <= 0x303E) || # CJK Radicals..CJK Symbols
1454
+ (cp >= 0x3040 && cp <= 0x33BF) || # Hiragana..CJK Compat
1455
+ (cp >= 0x3400 && cp <= 0x4DBF) || # CJK Unified Ext A
1456
+ (cp >= 0x4E00 && cp <= 0xA4CF) || # CJK Unified..Yi
1457
+ (cp >= 0xA960 && cp <= 0xA97C) || # Hangul Jamo Extended-A
1458
+ (cp >= 0xAC00 && cp <= 0xD7A3) || # Hangul Syllables
1459
+ (cp >= 0xF900 && cp <= 0xFAFF) || # CJK Compat Ideographs
1460
+ (cp >= 0xFE10 && cp <= 0xFE6F) || # Vertical forms..CJK Compat Forms
1461
+ (cp >= 0xFF01 && cp <= 0xFF60) || # Fullwidth Forms
1462
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || # Fullwidth Signs
1463
+ (cp >= 0x1F000 && cp <= 0x1FBFF) || # Emoji & symbols
1464
+ (cp >= 0x20000 && cp <= 0x3FFFF) # CJK Unified Ext B-G
1465
+ 1
1466
+ end
1467
+ end
1468
+ end