tui-td 0.2.10 → 0.2.12

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.
@@ -1,723 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module TUITD
4
- # Parses raw terminal output (ANSI escape sequences + text) into a
5
- # structured state representation.
6
- #
7
- # Handles:
8
- # - SGR (Select Graphic Rendition) — colors, bold, italic, underline
9
- # - Cursor movement (CUU, CUD, CUF, CUB, CUP)
10
- # - Erase (ED, EL)
11
- # - Line feed, carriage return, backspace, tab
12
- #
13
- # Output: {rows: [[{char, fg, bg, bold, italic, underline}]], cursor: {row, col}, size: {rows, cols}}
14
- #
15
- module ANSIParser
16
- SGR_COLORS = {
17
- 0 => :reset,
18
- 1 => :bold,
19
- 3 => :italic,
20
- 4 => :underline,
21
- 5 => :blink,
22
- 7 => :reverse,
23
- 22 => :normal,
24
- 23 => :no_italic,
25
- 24 => :no_underline,
26
- 30 => :black,
27
- 31 => :red,
28
- 32 => :green,
29
- 33 => :yellow,
30
- 34 => :blue,
31
- 35 => :magenta,
32
- 36 => :cyan,
33
- 37 => :white,
34
- 38 => :xterm_fg, # 38;5;N or 38;2;R;G;B
35
- 39 => :default_fg,
36
- 40 => :bg_black,
37
- 41 => :bg_red,
38
- 42 => :bg_green,
39
- 43 => :bg_yellow,
40
- 44 => :bg_blue,
41
- 45 => :bg_magenta,
42
- 46 => :bg_cyan,
43
- 47 => :bg_white,
44
- 48 => :xterm_bg, # 48;5;N or 48;2;R;G;B
45
- 49 => :default_bg,
46
- 90 => :bright_black,
47
- 91 => :bright_red,
48
- 92 => :bright_green,
49
- 93 => :bright_yellow,
50
- 94 => :bright_blue,
51
- 95 => :bright_magenta,
52
- 96 => :bright_cyan,
53
- 97 => :bright_white,
54
- 100 => :bg_bright_black,
55
- 101 => :bg_bright_red,
56
- 102 => :bg_bright_green,
57
- 103 => :bg_bright_yellow,
58
- 104 => :bg_bright_blue,
59
- 105 => :bg_bright_magenta,
60
- 106 => :bg_bright_cyan,
61
- 107 => :bg_bright_white,
62
- }.freeze
63
-
64
- SGR_16_TO_NAME = {
65
- 0 => "black",
66
- 1 => "red",
67
- 2 => "green",
68
- 3 => "yellow",
69
- 4 => "blue",
70
- 5 => "magenta",
71
- 6 => "cyan",
72
- 7 => "white",
73
- 8 => "bright_black",
74
- 9 => "bright_red",
75
- 10 => "bright_green",
76
- 11 => "bright_yellow",
77
- 12 => "bright_blue",
78
- 13 => "bright_magenta",
79
- 14 => "bright_cyan",
80
- 15 => "bright_white",
81
- }.freeze
82
-
83
- DEC_MAP = {
84
- '`' => '◆',
85
- 'a' => '▒',
86
- 'b' => "\u2409",
87
- 'c' => "\u240C",
88
- 'd' => "\u240D",
89
- 'e' => "\u240A",
90
- 'f' => '°',
91
- 'g' => '±',
92
- 'h' => "\u2424",
93
- 'i' => "\u240B",
94
- 'j' => '┘',
95
- 'k' => '┐',
96
- 'l' => '┌',
97
- 'm' => '└',
98
- 'n' => '┼',
99
- 'o' => '⎺',
100
- 'p' => '⎻',
101
- 'q' => '─',
102
- 'r' => '⎼',
103
- 's' => '⎽',
104
- 't' => '├',
105
- 'u' => '┤',
106
- 'v' => '┴',
107
- 'w' => '┬',
108
- 'x' => '│',
109
- 'y' => '≤',
110
- 'z' => '≥',
111
- '{' => 'π',
112
- '|' => '≠',
113
- '}' => '£',
114
- '~' => '·'
115
- }.freeze
116
-
117
- # Parse raw terminal output into a structured state Hash
118
- def self.parse(raw, rows = 40, cols = 120)
119
- grid = Array.new(rows) do
120
- Array.new(cols) { default_cell.dup }
121
- end
122
-
123
- cursor = { row: 0, col: 0 }
124
- attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
125
- saved_cursor = nil
126
- scroll_region = { top: 0, bottom: rows - 1 }
127
- pending_dsr = false
128
-
129
- normal_grid = grid
130
- alt_grid = nil
131
-
132
- normal_cursor = cursor
133
- alt_cursor = { row: 0, col: 0 }
134
-
135
- normal_saved_cursor = nil
136
- alt_saved_cursor = nil
137
-
138
- use_alt_screen = false
139
-
140
- cursor_visible = true
141
- cursor_style = 1 # 1 = blinking block (default)
142
-
143
- g0_charset = :ascii
144
- g1_charset = :dec
145
- active_charset = :g0
146
-
147
- mouse_mode = :none
148
- mouse_format = :normal
149
-
150
- # Strip everything before the last full clear (if any)
151
- # to avoid accumulated garbage
152
- processed = raw
153
-
154
- i = 0
155
- while i < processed.length
156
- if processed[i] == "\e" && processed[i + 1] == "["
157
- # Find end of CSI sequence
158
- j = i + 2
159
- j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@\`fhlmnRrsuq]/)
160
- seq = processed[i..j]
161
-
162
- dsr, new_saved, action = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
163
- pending_dsr ||= dsr
164
-
165
- if new_saved
166
- if use_alt_screen
167
- alt_saved_cursor = new_saved
168
- saved_cursor = alt_saved_cursor
169
- else
170
- normal_saved_cursor = new_saved
171
- saved_cursor = normal_saved_cursor
172
- end
173
- end
174
-
175
- if action.key?(:alt_screen)
176
- new_alt = action[:alt_screen]
177
- code = action[:alt_screen_code]
178
- if new_alt != use_alt_screen
179
- if new_alt
180
- # Switch to Alternate Screen
181
- # Save normal cursor
182
- normal_cursor = { row: cursor[:row], col: cursor[:col] }
183
-
184
- # Lazy initialize alt grid
185
- alt_grid ||= Array.new(rows) do
186
- Array.new(cols) { default_cell.dup }
187
- end
188
-
189
- # For \e[?1049h, clear alternate screen and reset cursor to 0,0
190
- if code == 1049
191
- alt_grid = Array.new(rows) do
192
- Array.new(cols) { default_cell.dup }
193
- end
194
- alt_cursor = { row: 0, col: 0 }
195
- end
196
-
197
- grid = alt_grid
198
- cursor = alt_cursor
199
- saved_cursor = alt_saved_cursor
200
- use_alt_screen = true
201
- else
202
- # Switch to Normal Screen
203
- # Save alt cursor
204
- alt_cursor = { row: cursor[:row], col: cursor[:col] }
205
-
206
- grid = normal_grid
207
- cursor = normal_cursor
208
- saved_cursor = normal_saved_cursor
209
- use_alt_screen = false
210
- end
211
- end
212
- end
213
-
214
- if action.key?(:cursor_visible)
215
- cursor_visible = action[:cursor_visible]
216
- end
217
-
218
- if action.key?(:cursor_style)
219
- cursor_style = action[:cursor_style]
220
- end
221
-
222
- if action.key?(:mouse_mode)
223
- mouse_mode = action[:mouse_mode]
224
- end
225
-
226
- if action.key?(:mouse_format)
227
- mouse_format = action[:mouse_format]
228
- end
229
-
230
- i = j + 1
231
- elsif processed[i] == "\n" || processed[i] == "\r\n"
232
- cursor[:row] += 1
233
- cursor[:col] = 0
234
- i += processed[i..i + 1] == "\r\n" ? 2 : 1
235
- elsif processed[i] == "\r"
236
- cursor[:col] = 0
237
- i += 1
238
- elsif processed[i] == "\t"
239
- cursor[:col] = ((cursor[:col] / 8) + 1) * 8
240
- cursor[:col] = cols - 1 if cursor[:col] >= cols
241
- i += 1
242
- elsif processed[i] == "\b"
243
- cursor[:col] -= 1 if cursor[:col] > 0
244
- i += 1
245
- elsif processed[i] == "\a"
246
- # Bell — ignore
247
- i += 1
248
- elsif processed[i] == "\x0e"
249
- active_charset = :g1
250
- i += 1
251
- elsif processed[i] == "\x0f"
252
- active_charset = :g0
253
- i += 1
254
- elsif processed[i] == "\e"
255
- # Handle non-CSI escape sequences
256
- if processed[i + 1] == "7"
257
- # DECSC — Save Cursor
258
- if use_alt_screen
259
- alt_saved_cursor = { row: cursor[:row], col: cursor[:col] }
260
- saved_cursor = alt_saved_cursor
261
- else
262
- normal_saved_cursor = { row: cursor[:row], col: cursor[:col] }
263
- saved_cursor = normal_saved_cursor
264
- end
265
- i += 2
266
- elsif processed[i + 1] == "8"
267
- # DECRC — Restore Cursor
268
- if saved_cursor
269
- cursor[:row] = saved_cursor[:row]
270
- cursor[:col] = saved_cursor[:col]
271
- end
272
- i += 2
273
- elsif processed[i + 1] == "(" && (processed[i + 2] == "0" || processed[i + 2] == "B")
274
- g0_charset = (processed[i + 2] == "0" ? :dec : :ascii)
275
- i += 3
276
- elsif processed[i + 1] == ")" && (processed[i + 2] == "0" || processed[i + 2] == "B")
277
- g1_charset = (processed[i + 2] == "0" ? :dec : :ascii)
278
- i += 3
279
- elsif processed[i + 1] && processed[i + 1].match?(/[()*+\-.\/]/)
280
- # Other ISO 2022 charset sequences (e.g. G2/G3 or other charsets)
281
- i += 3
282
- else
283
- i += 1
284
- end
285
- elsif (char, char_len = _utf8_char_at(processed, i))
286
- # Printable character (including multi-byte UTF-8)
287
- if cursor[:row] < rows && cursor[:col] < cols
288
- cell = grid[cursor[:row]][cursor[:col]]
289
- current_charset = (active_charset == :g1 ? g1_charset : g0_charset)
290
- mapped_char = char
291
- if current_charset == :dec && DEC_MAP.key?(char)
292
- mapped_char = DEC_MAP[char]
293
- end
294
- cell[:char] = mapped_char
295
- cell.merge!(attrs)
296
- cursor[:col] += 1
297
- cursor[:col] = cols - 1 if cursor[:col] >= cols
298
- end
299
- i += char_len
300
- else
301
- i += 1
302
- end
303
-
304
- # Handle scrolling within the defined scroll region
305
- region_top = scroll_region[:top]
306
- region_bottom = scroll_region[:bottom]
307
-
308
- if cursor[:row] > region_bottom
309
- scroll_lines = [cursor[:row] - region_bottom, rows].min
310
- # Shift lines within the scroll region up
311
- (region_top..(region_bottom - scroll_lines)).each do |ri|
312
- src = ri + scroll_lines
313
- grid[ri] = src <= region_bottom ? grid[src] : Array.new(cols) { default_cell.dup }
314
- end
315
- # Fill bottom of scroll region with blank lines
316
- ((region_bottom - scroll_lines + 1)..region_bottom).each do |ri|
317
- grid[ri] = Array.new(cols) { default_cell.dup }
318
- end
319
- cursor[:row] = region_bottom
320
- end
321
- end
322
-
323
- {
324
- size: { rows: rows, cols: cols },
325
- cursor: {
326
- row: cursor[:row],
327
- col: cursor[:col],
328
- visible: cursor_visible,
329
- style: cursor_style
330
- },
331
- rows: grid,
332
- pending_dsr: pending_dsr,
333
- cursor_visible: cursor_visible,
334
- cursor_style: cursor_style,
335
- mouse_mode: mouse_mode,
336
- mouse_format: mouse_format
337
- }
338
- end
339
-
340
- # Rebuild ANSI output from a state hash (for rendering/screenshot)
341
- def self.build_frame(state)
342
- rows = state.dig(:size, :rows) || state["size"]["rows"]
343
- cols = state.dig(:size, :cols) || state["size"]["cols"]
344
- grid = state[:rows] || state["rows"]
345
- cursor = state[:cursor] || state["cursor"]
346
- mouse_mode = state[:mouse_mode] || state["mouse_mode"] || :none
347
- mouse_format = state[:mouse_format] || state["mouse_format"] || :normal
348
-
349
- out = +""
350
- out << "\e[0m"
351
- out << "\e[2J\e[H"
352
-
353
- grid.each_with_index do |row, ri|
354
- row.each_with_index do |cell, ci|
355
- char = cell[:char] || cell["char"] || " "
356
- fg = cell[:fg] || cell["fg"] || "default"
357
- bg = cell[:bg] || cell["bg"] || "default"
358
- bold = cell[:bold] || cell["bold"] || false
359
- italic = cell[:italic] || cell["italic"] || false
360
- underline = cell[:underline] || cell["underline"] || false
361
- blink = cell[:blink] || cell["blink"] || false
362
-
363
- codes = []
364
- codes << "1" if bold
365
- codes << "3" if italic
366
- codes << "4" if underline
367
- codes << "5" if blink
3
+ require "tans-parser"
368
4
 
369
- fg_code = _color_code(fg, "38")
370
- bg_code = _color_code(bg, "48")
371
-
372
- codes << fg_code if fg_code
373
- codes << bg_code if bg_code
374
-
375
- out << "\e[#{codes.join(";")}m" unless codes.empty?
376
- out << char
377
- end
378
- out << "\n" if ri < rows - 1
379
- end
380
-
381
- # Reconstruct cursor visibility
382
- cursor_vis = true
383
- if cursor.is_a?(Hash)
384
- cursor_vis = cursor[:visible] != false && cursor["visible"] != false
385
- end
386
- out << (cursor_vis ? "\e[?25h" : "\e[?25l")
387
-
388
- # Reconstruct cursor style
389
- if cursor.is_a?(Hash)
390
- style = cursor[:style] || cursor["style"]
391
- out << "\e[#{style} q" if style
392
- end
393
-
394
- # Reconstruct mouse mode and format
395
- if mouse_mode == :normal
396
- out << "\e[?1000h"
397
- elsif mouse_mode == :drag
398
- out << "\e[?1002h"
399
- elsif mouse_mode == :all
400
- out << "\e[?1003h"
401
- else
402
- out << "\e[?1000l\e[?1002l\e[?1003l"
403
- end
404
-
405
- if mouse_format == :sgr
406
- out << "\e[?1006h"
407
- else
408
- out << "\e[?1006l"
409
- end
410
-
411
- out << "\e[0m"
412
- out
413
- end
414
-
415
- def self._apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
416
- # Strip leading escape char if present
417
- cleaned = seq.sub(/^\e/, "")
418
- match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX@\`fhlmnRrsuq])$/)
419
- return [false, nil, {}] unless match
420
-
421
- is_private = (match[1] == "?")
422
- params = match[2].split(";").map(&:to_i)
423
- space = match[3]
424
- command = match[4]
425
-
426
- new_saved = nil
427
- action = {}
428
-
429
- case command
430
- when "m"
431
- _apply_sgr(params, attrs)
432
- when "A" # CUU — Cursor Up
433
- n = params[0] || 1
434
- n = 1 if n == 0
435
- cursor[:row] = [cursor[:row] - n, 0].max
436
- when "B" # CUD — Cursor Down
437
- n = params[0] || 1
438
- n = 1 if n == 0
439
- cursor[:row] = [cursor[:row] + n, rows - 1].min
440
- when "C" # CUF — Cursor Forward
441
- n = params[0] || 1
442
- n = 1 if n == 0
443
- cursor[:col] = [cursor[:col] + n, cols - 1].min
444
- when "D" # CUB — Cursor Back
445
- n = params[0] || 1
446
- n = 1 if n == 0
447
- cursor[:col] = [cursor[:col] - n, 0].max
448
- when "H", "f" # CUP — Cursor Position
449
- r = (params[0] || 1) - 1
450
- c = (params[1] || 1) - 1
451
- cursor[:row] = r.clamp(0, rows - 1)
452
- cursor[:col] = c.clamp(0, cols - 1)
453
- when "J" # ED — Erase in Display
454
- case params[0]
455
- when nil, 0
456
- _erase_down(cursor, grid, rows, cols)
457
- when 1
458
- _erase_up(cursor, grid, cols)
459
- when 2, 3
460
- _erase_all(grid, rows, cols)
461
- cursor[:row] = 0
462
- cursor[:col] = 0
463
- end
464
- when "K" # EL — Erase in Line
465
- case params[0]
466
- when nil, 0
467
- _erase_line_right(cursor, grid, cols)
468
- when 1
469
- _erase_line_left(cursor, grid, cols)
470
- when 2
471
- _erase_line(cursor, grid, cols)
472
- end
473
- when "X" # Erase Characters
474
- n = params[0] || 1
475
- n.times do |i|
476
- next unless cursor[:row] < rows && cursor[:col] + i < cols
477
- grid[cursor[:row]][cursor[:col] + i][:char] = " "
478
- end
479
- when "s" # DECSC — Save Cursor (CSI variant)
480
- new_saved = { row: cursor[:row], col: cursor[:col] }
481
- when "u" # DECRC — Restore Cursor (CSI variant)
482
- if saved_cursor
483
- cursor[:row] = saved_cursor[:row]
484
- cursor[:col] = saved_cursor[:col]
485
- end
486
- when "r" # DECSTBM — Set Scroll Region
487
- top = (params[0] || 1) - 1
488
- bottom = (params[1] || rows) - 1
489
- top = top.clamp(0, rows - 1)
490
- bottom = bottom.clamp(0, rows - 1)
491
- if top < bottom
492
- scroll_region[:top] = top
493
- scroll_region[:bottom] = bottom
494
- else
495
- scroll_region[:top] = 0
496
- scroll_region[:bottom] = rows - 1
497
- end
498
- cursor[:row] = 0
499
- cursor[:col] = 0
500
- when "h"
501
- if is_private
502
- params.each do |p|
503
- case p
504
- when 47, 1047, 1049
505
- action[:alt_screen] = true
506
- action[:alt_screen_code] = p
507
- when 25
508
- action[:cursor_visible] = true
509
- when 1000
510
- action[:mouse_mode] = :normal
511
- when 1002
512
- action[:mouse_mode] = :drag
513
- when 1003
514
- action[:mouse_mode] = :all
515
- when 1006
516
- action[:mouse_format] = :sgr
517
- end
518
- end
519
- end
520
- when "l"
521
- if is_private
522
- params.each do |p|
523
- case p
524
- when 47, 1047, 1049
525
- action[:alt_screen] = false
526
- action[:alt_screen_code] = p
527
- when 25
528
- action[:cursor_visible] = false
529
- when 1000, 1002, 1003
530
- action[:mouse_mode] = :none
531
- when 1006
532
- action[:mouse_format] = :normal
533
- end
534
- end
535
- end
536
- when "q"
537
- if space == " "
538
- style_val = params[0] || 0
539
- action[:cursor_style] = style_val
540
- end
541
- when "n" # DSR — Device Status Report request
542
- return [params[0] == 6, nil, {}]
543
- when "R" # DSR response (from terminal side) or CPR — ignore
544
- nil
545
- end
546
-
547
- [false, new_saved, action]
548
- end
549
-
550
- def self._apply_sgr(params, attrs)
551
- return attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false) if params.empty? || params == [0]
552
-
553
- i = 0
554
- while i < params.length
555
- p = params[i]
556
- case p
557
- when 0
558
- attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false)
559
- when 1
560
- attrs[:bold] = true
561
- when 3
562
- attrs[:italic] = true
563
- when 4
564
- attrs[:underline] = true
565
- when 5, 6
566
- attrs[:blink] = true
567
- when 22
568
- attrs[:bold] = false
569
- when 23
570
- attrs[:italic] = false
571
- when 24
572
- attrs[:underline] = false
573
- when 25
574
- attrs[:blink] = false
575
- when 7
576
- # Reverse — swap fg and bg
577
- attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
578
- when 27
579
- attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
580
- when 30..37
581
- attrs[:fg] = SGR_16_TO_NAME[p - 30] || "color#{p - 30}"
582
- when 38
583
- # Extended foreground
584
- if params[i + 1] == 5
585
- color = params[i + 2]
586
- attrs[:fg] = "color#{color}"
587
- i += 2
588
- elsif params[i + 1] == 2
589
- r, g, b = params[i + 2], params[i + 3], params[i + 4]
590
- attrs[:fg] = format("#%02x%02x%02x", r, g, b)
591
- i += 4
592
- end
593
- when 39
594
- attrs[:fg] = "default"
595
- when 40..47
596
- attrs[:bg] = SGR_16_TO_NAME[p - 40] || "bg_color#{p - 40}"
597
- when 48
598
- # Extended background
599
- if params[i + 1] == 5
600
- color = params[i + 2]
601
- attrs[:bg] = "color#{color}"
602
- i += 2
603
- elsif params[i + 1] == 2
604
- r, g, b = params[i + 2], params[i + 3], params[i + 4]
605
- attrs[:bg] = format("#%02x%02x%02x", r, g, b)
606
- i += 4
607
- end
608
- when 49
609
- attrs[:bg] = "default"
610
- when 90..97
611
- attrs[:fg] = "bright_#{SGR_16_TO_NAME[p - 90] || "color#{p - 90 + 8}"}"
612
- when 100..107
613
- attrs[:bg] = "bright_#{SGR_16_TO_NAME[p - 100] || "color#{p - 100 + 8}"}"
614
- end
615
- i += 1
616
- end
617
- end
618
-
619
- def self._erase_down(cursor, grid, rows, cols)
620
- r = cursor[:row]
621
- c = cursor[:col]
622
-
623
- # Erase from cursor to end of line
624
- (c...cols).each { |ci| grid[r][ci][:char] = " " if r < rows }
625
-
626
- # Erase remaining lines
627
- ((r + 1)...rows).each do |ri|
628
- cols.times { |ci| grid[ri][ci][:char] = " " }
629
- end
630
- end
631
-
632
- def self._erase_up(cursor, grid, cols)
633
- r = cursor[:row]
634
- c = cursor[:col]
635
-
636
- # Erase lines above cursor
637
- (0...r).each do |ri|
638
- cols.times { |ci| grid[ri][ci][:char] = " " }
639
- end
640
-
641
- # Erase from start of line to cursor
642
- (0..c).each { |ci| grid[r][ci][:char] = " " }
643
- end
644
-
645
- def self._erase_all(grid, rows, cols)
646
- rows.times do |ri|
647
- cols.times { |ci| grid[ri][ci][:char] = " " }
648
- end
649
- end
650
-
651
- def self._erase_line_right(cursor, grid, cols)
652
- r = cursor[:row]
653
- c = cursor[:col]
654
- (c...cols).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
655
- end
656
-
657
- def self._erase_line_left(cursor, grid, cols)
658
- r = cursor[:row]
659
- c = cursor[:col]
660
- (0..c).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
661
- end
662
-
663
- def self._erase_line(cursor, grid, cols)
664
- r = cursor[:row]
665
- cols.times { |ci| grid[r][ci][:char] = " " if r < grid.length }
666
- end
667
-
668
- def self._color_code(name, prefix)
669
- case name
670
- when "default" then nil
671
- when /^#([0-9a-fA-F]{6})$/
672
- r = $1[0..1].to_i(16)
673
- g = $1[2..3].to_i(16)
674
- b = $1[4..5].to_i(16)
675
- "#{prefix};2;#{r};#{g};#{b}"
676
- when /^(bright_)?(.+)$/
677
- base_name = $2
678
- index = SGR_16_TO_NAME.key(base_name)
679
- index += 8 if $1 && index && index < 8
680
- index ? "#{prefix};5;#{index}" : nil
681
- else
682
- nil
683
- end
684
- end
685
-
686
- def self.default_cell
687
- { char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
688
- end
689
-
690
- # Extract a single UTF-8 character at position i in a binary string.
691
- # Returns [char_string, byte_length] or nil if the byte is not printable/valid.
692
- def self._utf8_char_at(str, i)
693
- byte = str.getbyte(i)
694
- return nil unless byte
695
-
696
- if byte < 0x80
697
- # Single-byte ASCII
698
- return nil unless byte >= 0x20 # only printable, skip control chars
699
- return [byte.chr, 1]
700
- end
701
-
702
- # Multi-byte UTF-8
703
- len = if byte & 0xE0 == 0xC0
704
- 2
705
- elsif byte & 0xF0 == 0xE0
706
- 3
707
- elsif byte & 0xF8 == 0xF0
708
- 4
709
- else
710
- return nil # continuation byte or invalid — let main loop advance
711
- end
712
- return nil if i + len > str.bytesize
713
-
714
- bytes = str.byteslice(i, len)
715
- char = bytes.dup.force_encoding("UTF-8")
716
- return nil unless char.valid_encoding?
717
-
718
- [char, len]
719
- rescue StandardError
720
- nil
721
- end
722
- end
5
+ module TUITD
6
+ ANSIParser = TansParser::ANSIParser
723
7
  end