tui-td 0.2.11 → 0.2.13

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