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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/lib/tui_td/ansi_parser.rb +3 -719
- data/lib/tui_td/ansi_utils.rb +3 -71
- data/lib/tui_td/cairo_renderer.rb +5 -2
- data/lib/tui_td/cli.rb +12 -10
- data/lib/tui_td/driver.rb +54 -14
- data/lib/tui_td/html_renderer.rb +19 -17
- data/lib/tui_td/matchers.rb +21 -12
- data/lib/tui_td/mcp/server.rb +104 -87
- data/lib/tui_td/screenshot.rb +70 -52
- data/lib/tui_td/state.rb +3 -117
- data/lib/tui_td/test_runner.rb +41 -27
- data/lib/tui_td/unifont_glyphs.rb +2142 -2141
- data/lib/tui_td/version.rb +1 -1
- data/lib/tui_td.rb +7 -3
- metadata +50 -7
data/lib/tui_td/ansi_parser.rb
CHANGED
|
@@ -1,723 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|