tui-td 0.2.9 → 0.2.11
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 +17 -0
- data/README.md +16 -1
- data/lib/tui_td/ansi_parser.rb +139 -131
- data/lib/tui_td/ansi_utils.rb +22 -20
- data/lib/tui_td/cairo_renderer.rb +5 -2
- data/lib/tui_td/cli.rb +12 -10
- data/lib/tui_td/driver.rb +43 -12
- data/lib/tui_td/html_renderer.rb +19 -17
- data/lib/tui_td/matchers.rb +21 -12
- data/lib/tui_td/mcp/server.rb +146 -76
- data/lib/tui_td/screenshot.rb +70 -52
- data/lib/tui_td/state.rb +11 -4
- data/lib/tui_td/test_runner.rb +25 -25
- 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 +40 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07ef26981840d9171eb2082a6c638c6f43869c05ffdb11c7fff857b058ce87e7
|
|
4
|
+
data.tar.gz: 47d8dd453c1a393ad2f838b67226f92d50b97d5647fb775e36e95f9e381105e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94ce617f9370428126bf1b565ca67787a455f6d64cd6273fc69c2cecc83ca293b07272cf3f227787ba4e56ae4bf914447752689115979d1d19863fd5d1e17545
|
|
7
|
+
data.tar.gz: fdc72eaff712400c026469730cce5fe88daf65b2cca7fa25a071d79a1a2d53b648f5cd80dc8f2b11fe952ce1c9b4aeed9dde5e372715c22d877487d1db25cb52
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
# CHANGELOG
|
|
4
|
+
|
|
5
|
+
## 0.2.11
|
|
6
|
+
|
|
7
|
+
- Add RuboCop (rubocop-rake, rubocop-rspec), Reek, and Bundler-Audit linters
|
|
8
|
+
- Pre-commit hook runs all three checks automatically
|
|
9
|
+
- Fix dead code after `raise` in test_runner.rb
|
|
10
|
+
- Rename `is_cursor?` to `cursor_at?` in html_renderer.rb
|
|
11
|
+
- Merge duplicate `describe "#find_text"` blocks in state_spec.rb
|
|
12
|
+
- Fix ANSI parser `FormatStringToken` warnings
|
|
13
|
+
|
|
14
|
+
## 0.2.10
|
|
15
|
+
|
|
16
|
+
- Three new MCP tools: `tui_wait_for_exit` (wait for process to end), `tui_exit_status` (get exit code), `tui_find_text` (search terminal state for text/regex matches)
|
|
17
|
+
- Document `tui_html_render` MCP tool in README (was already implemented but missing from docs)
|
|
18
|
+
- Smoke test expanded to 63 assertions covering all 13 MCP tools including new ones
|
|
19
|
+
|
|
3
20
|
## 0.2.9
|
|
4
21
|
|
|
5
22
|
- Fix: `wait_for_stable` uses parsed terminal grid comparison instead of raw byte arrival, preventing interactive TUIs that repaint cell-by-cell (e.g., glow) from timing out
|
data/README.md
CHANGED
|
@@ -369,6 +369,10 @@ tui-td serve
|
|
|
369
369
|
| `tui_state` | Get terminal state: AI-friendly compact mode (default), `full` grid, or `text` only. |
|
|
370
370
|
| `tui_plain_text` | Get plain text content, ANSI stripped. |
|
|
371
371
|
| `tui_screenshot` | Capture a PNG screenshot of the current terminal. |
|
|
372
|
+
| `tui_html_render` | Render terminal state as a self-contained HTML document. Returns HTML inline or saves to file. |
|
|
373
|
+
| `tui_wait_for_exit` | Wait until the TUI process exits. Returns exit status. |
|
|
374
|
+
| `tui_exit_status` | Get the exit status code (nil if still running). |
|
|
375
|
+
| `tui_find_text` | Search for text or regex in terminal state. Returns positions of all matches. |
|
|
372
376
|
| `tui_close` | Close the TUI and clean up. |
|
|
373
377
|
|
|
374
378
|
### MCP configuration
|
|
@@ -407,7 +411,18 @@ Add to your MCP client configuration:
|
|
|
407
411
|
// 6. Take screenshot if needed
|
|
408
412
|
{"method": "tools/call", "params": {"name": "tui_screenshot", "arguments": {"path": "/tmp/proof.png"}}}
|
|
409
413
|
|
|
410
|
-
// 7.
|
|
414
|
+
// 7. Render as HTML (save to file or get inline)
|
|
415
|
+
{"method": "tools/call", "params": {"name": "tui_html_render", "arguments": {"path": "/tmp/proof.html"}}}
|
|
416
|
+
// Or without path to get HTML inline:
|
|
417
|
+
// {"method": "tools/call", "params": {"name": "tui_html_render", "arguments": {}}}
|
|
418
|
+
|
|
419
|
+
// 8. Search for text in the terminal
|
|
420
|
+
{"method": "tools/call", "params": {"name": "tui_find_text", "arguments": {"pattern": "error|fail"}}}
|
|
421
|
+
|
|
422
|
+
// 9. Check exit status (or wait for exit)
|
|
423
|
+
{"method": "tools/call", "params": {"name": "tui_exit_status", "arguments": {}}}
|
|
424
|
+
|
|
425
|
+
// 10. Clean up
|
|
411
426
|
{"method": "tools/call", "params": {"name": "tui_close", "arguments": {}}}
|
|
412
427
|
```
|
|
413
428
|
|
data/lib/tui_td/ansi_parser.rb
CHANGED
|
@@ -12,14 +12,15 @@ module TUITD
|
|
|
12
12
|
#
|
|
13
13
|
# Output: {rows: [[{char, fg, bg, bold, italic, underline}]], cursor: {row, col}, size: {rows, cols}}
|
|
14
14
|
#
|
|
15
|
+
# rubocop:disable Metrics/ModuleLength
|
|
15
16
|
module ANSIParser
|
|
16
17
|
SGR_COLORS = {
|
|
17
|
-
0
|
|
18
|
-
1
|
|
19
|
-
3
|
|
20
|
-
4
|
|
21
|
-
5
|
|
22
|
-
7
|
|
18
|
+
0 => :reset,
|
|
19
|
+
1 => :bold,
|
|
20
|
+
3 => :italic,
|
|
21
|
+
4 => :underline,
|
|
22
|
+
5 => :blink,
|
|
23
|
+
7 => :reverse,
|
|
23
24
|
22 => :normal,
|
|
24
25
|
23 => :no_italic,
|
|
25
26
|
24 => :no_underline,
|
|
@@ -62,16 +63,16 @@ module TUITD
|
|
|
62
63
|
}.freeze
|
|
63
64
|
|
|
64
65
|
SGR_16_TO_NAME = {
|
|
65
|
-
0
|
|
66
|
-
1
|
|
67
|
-
2
|
|
68
|
-
3
|
|
69
|
-
4
|
|
70
|
-
5
|
|
71
|
-
6
|
|
72
|
-
7
|
|
73
|
-
8
|
|
74
|
-
9
|
|
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",
|
|
75
76
|
10 => "bright_green",
|
|
76
77
|
11 => "bright_yellow",
|
|
77
78
|
12 => "bright_blue",
|
|
@@ -81,40 +82,41 @@ module TUITD
|
|
|
81
82
|
}.freeze
|
|
82
83
|
|
|
83
84
|
DEC_MAP = {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
"~" => "·",
|
|
115
116
|
}.freeze
|
|
116
117
|
|
|
117
118
|
# Parse raw terminal output into a structured state Hash
|
|
119
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
118
120
|
def self.parse(raw, rows = 40, cols = 120)
|
|
119
121
|
grid = Array.new(rows) do
|
|
120
122
|
Array.new(cols) { default_cell.dup }
|
|
@@ -156,7 +158,7 @@ module TUITD
|
|
|
156
158
|
if processed[i] == "\e" && processed[i + 1] == "["
|
|
157
159
|
# Find end of CSI sequence
|
|
158
160
|
j = i + 2
|
|
159
|
-
j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX
|
|
161
|
+
j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fhlmnRrsuq]/)
|
|
160
162
|
seq = processed[i..j]
|
|
161
163
|
|
|
162
164
|
dsr, new_saved, action = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
@@ -211,27 +213,19 @@ module TUITD
|
|
|
211
213
|
end
|
|
212
214
|
end
|
|
213
215
|
|
|
214
|
-
if action.key?(:cursor_visible)
|
|
215
|
-
cursor_visible = action[:cursor_visible]
|
|
216
|
-
end
|
|
216
|
+
cursor_visible = action[:cursor_visible] if action.key?(:cursor_visible)
|
|
217
217
|
|
|
218
|
-
if action.key?(:cursor_style)
|
|
219
|
-
cursor_style = action[:cursor_style]
|
|
220
|
-
end
|
|
218
|
+
cursor_style = action[:cursor_style] if action.key?(:cursor_style)
|
|
221
219
|
|
|
222
|
-
if action.key?(:mouse_mode)
|
|
223
|
-
mouse_mode = action[:mouse_mode]
|
|
224
|
-
end
|
|
220
|
+
mouse_mode = action[:mouse_mode] if action.key?(:mouse_mode)
|
|
225
221
|
|
|
226
|
-
if action.key?(:mouse_format)
|
|
227
|
-
mouse_format = action[:mouse_format]
|
|
228
|
-
end
|
|
222
|
+
mouse_format = action[:mouse_format] if action.key?(:mouse_format)
|
|
229
223
|
|
|
230
224
|
i = j + 1
|
|
231
|
-
elsif
|
|
225
|
+
elsif ["\n", "\r\n"].include?(processed[i])
|
|
232
226
|
cursor[:row] += 1
|
|
233
227
|
cursor[:col] = 0
|
|
234
|
-
i += processed[i..i + 1] == "\r\n" ? 2 : 1
|
|
228
|
+
i += processed[i..(i + 1)] == "\r\n" ? 2 : 1
|
|
235
229
|
elsif processed[i] == "\r"
|
|
236
230
|
cursor[:col] = 0
|
|
237
231
|
i += 1
|
|
@@ -240,7 +234,7 @@ module TUITD
|
|
|
240
234
|
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
241
235
|
i += 1
|
|
242
236
|
elsif processed[i] == "\b"
|
|
243
|
-
cursor[:col] -= 1 if cursor[:col]
|
|
237
|
+
cursor[:col] -= 1 if cursor[:col].positive?
|
|
244
238
|
i += 1
|
|
245
239
|
elsif processed[i] == "\a"
|
|
246
240
|
# Bell — ignore
|
|
@@ -270,13 +264,13 @@ module TUITD
|
|
|
270
264
|
cursor[:col] = saved_cursor[:col]
|
|
271
265
|
end
|
|
272
266
|
i += 2
|
|
273
|
-
elsif processed[i + 1] == "(" &&
|
|
267
|
+
elsif processed[i + 1] == "(" && %w[0 B].include?(processed[i + 2])
|
|
274
268
|
g0_charset = (processed[i + 2] == "0" ? :dec : :ascii)
|
|
275
269
|
i += 3
|
|
276
|
-
elsif processed[i + 1] == ")" &&
|
|
270
|
+
elsif processed[i + 1] == ")" && %w[0 B].include?(processed[i + 2])
|
|
277
271
|
g1_charset = (processed[i + 2] == "0" ? :dec : :ascii)
|
|
278
272
|
i += 3
|
|
279
|
-
elsif processed[i + 1]
|
|
273
|
+
elsif processed[i + 1]&.match?(%r{[()*+\-./]})
|
|
280
274
|
# Other ISO 2022 charset sequences (e.g. G2/G3 or other charsets)
|
|
281
275
|
i += 3
|
|
282
276
|
else
|
|
@@ -288,16 +282,14 @@ module TUITD
|
|
|
288
282
|
cell = grid[cursor[:row]][cursor[:col]]
|
|
289
283
|
current_charset = (active_charset == :g1 ? g1_charset : g0_charset)
|
|
290
284
|
mapped_char = char
|
|
291
|
-
if current_charset == :dec && DEC_MAP.key?(char)
|
|
292
|
-
mapped_char = DEC_MAP[char]
|
|
293
|
-
end
|
|
285
|
+
mapped_char = DEC_MAP[char] if current_charset == :dec && DEC_MAP.key?(char)
|
|
294
286
|
cell[:char] = mapped_char
|
|
295
287
|
cell.merge!(attrs)
|
|
296
288
|
cursor[:col] += 1
|
|
297
289
|
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
298
290
|
end
|
|
299
291
|
i += char_len
|
|
300
|
-
else
|
|
292
|
+
else # rubocop:disable Lint/DuplicateBranch
|
|
301
293
|
i += 1
|
|
302
294
|
end
|
|
303
295
|
|
|
@@ -305,19 +297,19 @@ module TUITD
|
|
|
305
297
|
region_top = scroll_region[:top]
|
|
306
298
|
region_bottom = scroll_region[:bottom]
|
|
307
299
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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 }
|
|
320
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
|
|
321
313
|
end
|
|
322
314
|
|
|
323
315
|
{
|
|
@@ -326,21 +318,23 @@ module TUITD
|
|
|
326
318
|
row: cursor[:row],
|
|
327
319
|
col: cursor[:col],
|
|
328
320
|
visible: cursor_visible,
|
|
329
|
-
style: cursor_style
|
|
321
|
+
style: cursor_style,
|
|
330
322
|
},
|
|
331
323
|
rows: grid,
|
|
332
324
|
pending_dsr: pending_dsr,
|
|
333
325
|
cursor_visible: cursor_visible,
|
|
334
326
|
cursor_style: cursor_style,
|
|
335
327
|
mouse_mode: mouse_mode,
|
|
336
|
-
mouse_format: mouse_format
|
|
328
|
+
mouse_format: mouse_format,
|
|
337
329
|
}
|
|
338
330
|
end
|
|
331
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
339
332
|
|
|
340
333
|
# Rebuild ANSI output from a state hash (for rendering/screenshot)
|
|
334
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
341
335
|
def self.build_frame(state)
|
|
342
336
|
rows = state.dig(:size, :rows) || state["size"]["rows"]
|
|
343
|
-
|
|
337
|
+
state.dig(:size, :cols) || state["size"]["cols"]
|
|
344
338
|
grid = state[:rows] || state["rows"]
|
|
345
339
|
cursor = state[:cursor] || state["cursor"]
|
|
346
340
|
mouse_mode = state[:mouse_mode] || state["mouse_mode"] || :none
|
|
@@ -351,7 +345,7 @@ module TUITD
|
|
|
351
345
|
out << "\e[2J\e[H"
|
|
352
346
|
|
|
353
347
|
grid.each_with_index do |row, ri|
|
|
354
|
-
row.each_with_index do |cell,
|
|
348
|
+
row.each_with_index do |cell, _ci|
|
|
355
349
|
char = cell[:char] || cell["char"] || " "
|
|
356
350
|
fg = cell[:fg] || cell["fg"] || "default"
|
|
357
351
|
bg = cell[:bg] || cell["bg"] || "default"
|
|
@@ -380,9 +374,7 @@ module TUITD
|
|
|
380
374
|
|
|
381
375
|
# Reconstruct cursor visibility
|
|
382
376
|
cursor_vis = true
|
|
383
|
-
if cursor.is_a?(Hash)
|
|
384
|
-
cursor_vis = cursor[:visible] != false && cursor["visible"] != false
|
|
385
|
-
end
|
|
377
|
+
cursor_vis = cursor[:visible] != false && cursor["visible"] != false if cursor.is_a?(Hash)
|
|
386
378
|
out << (cursor_vis ? "\e[?25h" : "\e[?25l")
|
|
387
379
|
|
|
388
380
|
# Reconstruct cursor style
|
|
@@ -392,30 +384,33 @@ module TUITD
|
|
|
392
384
|
end
|
|
393
385
|
|
|
394
386
|
# Reconstruct mouse mode and format
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
410
403
|
|
|
411
404
|
out << "\e[0m"
|
|
412
405
|
out
|
|
413
406
|
end
|
|
407
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
414
408
|
|
|
409
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
415
410
|
def self._apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
416
411
|
# Strip leading escape char if present
|
|
417
412
|
cleaned = seq.sub(/^\e/, "")
|
|
418
|
-
match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX
|
|
413
|
+
match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX@`fhlmnRrsuq])$/)
|
|
419
414
|
return [false, nil, {}] unless match
|
|
420
415
|
|
|
421
416
|
is_private = (match[1] == "?")
|
|
@@ -431,19 +426,19 @@ module TUITD
|
|
|
431
426
|
_apply_sgr(params, attrs)
|
|
432
427
|
when "A" # CUU — Cursor Up
|
|
433
428
|
n = params[0] || 1
|
|
434
|
-
n = 1 if n
|
|
429
|
+
n = 1 if n.zero?
|
|
435
430
|
cursor[:row] = [cursor[:row] - n, 0].max
|
|
436
431
|
when "B" # CUD — Cursor Down
|
|
437
432
|
n = params[0] || 1
|
|
438
|
-
n = 1 if n
|
|
433
|
+
n = 1 if n.zero?
|
|
439
434
|
cursor[:row] = [cursor[:row] + n, rows - 1].min
|
|
440
435
|
when "C" # CUF — Cursor Forward
|
|
441
436
|
n = params[0] || 1
|
|
442
|
-
n = 1 if n
|
|
437
|
+
n = 1 if n.zero?
|
|
443
438
|
cursor[:col] = [cursor[:col] + n, cols - 1].min
|
|
444
439
|
when "D" # CUB — Cursor Back
|
|
445
440
|
n = params[0] || 1
|
|
446
|
-
n = 1 if n
|
|
441
|
+
n = 1 if n.zero?
|
|
447
442
|
cursor[:col] = [cursor[:col] - n, 0].max
|
|
448
443
|
when "H", "f" # CUP — Cursor Position
|
|
449
444
|
r = (params[0] || 1) - 1
|
|
@@ -474,6 +469,7 @@ module TUITD
|
|
|
474
469
|
n = params[0] || 1
|
|
475
470
|
n.times do |i|
|
|
476
471
|
next unless cursor[:row] < rows && cursor[:col] + i < cols
|
|
472
|
+
|
|
477
473
|
grid[cursor[:row]][cursor[:col] + i][:char] = " "
|
|
478
474
|
end
|
|
479
475
|
when "s" # DECSC — Save Cursor (CSI variant)
|
|
@@ -546,9 +542,14 @@ module TUITD
|
|
|
546
542
|
|
|
547
543
|
[false, new_saved, action]
|
|
548
544
|
end
|
|
545
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
549
546
|
|
|
547
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
550
548
|
def self._apply_sgr(params, attrs)
|
|
551
|
-
|
|
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
|
|
552
553
|
|
|
553
554
|
i = 0
|
|
554
555
|
while i < params.length
|
|
@@ -572,11 +573,9 @@ module TUITD
|
|
|
572
573
|
attrs[:underline] = false
|
|
573
574
|
when 25
|
|
574
575
|
attrs[:blink] = false
|
|
575
|
-
when 7
|
|
576
|
+
when 7, 27
|
|
576
577
|
# Reverse — swap fg and bg
|
|
577
578
|
attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
|
|
578
|
-
when 27
|
|
579
|
-
attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
|
|
580
579
|
when 30..37
|
|
581
580
|
attrs[:fg] = SGR_16_TO_NAME[p - 30] || "color#{p - 30}"
|
|
582
581
|
when 38
|
|
@@ -586,8 +585,10 @@ module TUITD
|
|
|
586
585
|
attrs[:fg] = "color#{color}"
|
|
587
586
|
i += 2
|
|
588
587
|
elsif params[i + 1] == 2
|
|
589
|
-
r
|
|
590
|
-
|
|
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)
|
|
591
592
|
i += 4
|
|
592
593
|
end
|
|
593
594
|
when 39
|
|
@@ -601,8 +602,10 @@ module TUITD
|
|
|
601
602
|
attrs[:bg] = "color#{color}"
|
|
602
603
|
i += 2
|
|
603
604
|
elsif params[i + 1] == 2
|
|
604
|
-
r
|
|
605
|
-
|
|
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)
|
|
606
609
|
i += 4
|
|
607
610
|
end
|
|
608
611
|
when 49
|
|
@@ -615,6 +618,7 @@ module TUITD
|
|
|
615
618
|
i += 1
|
|
616
619
|
end
|
|
617
620
|
end
|
|
621
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
618
622
|
|
|
619
623
|
def self._erase_down(cursor, grid, rows, cols)
|
|
620
624
|
r = cursor[:row]
|
|
@@ -654,7 +658,7 @@ module TUITD
|
|
|
654
658
|
(c...cols).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
|
|
655
659
|
end
|
|
656
660
|
|
|
657
|
-
def self._erase_line_left(cursor, grid,
|
|
661
|
+
def self._erase_line_left(cursor, grid, _cols)
|
|
658
662
|
r = cursor[:row]
|
|
659
663
|
c = cursor[:col]
|
|
660
664
|
(0..c).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
|
|
@@ -665,23 +669,23 @@ module TUITD
|
|
|
665
669
|
cols.times { |ci| grid[r][ci][:char] = " " if r < grid.length }
|
|
666
670
|
end
|
|
667
671
|
|
|
672
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
668
673
|
def self._color_code(name, prefix)
|
|
669
674
|
case name
|
|
670
675
|
when "default" then nil
|
|
671
676
|
when /^#([0-9a-fA-F]{6})$/
|
|
672
|
-
r =
|
|
673
|
-
g =
|
|
674
|
-
b =
|
|
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)
|
|
675
680
|
"#{prefix};2;#{r};#{g};#{b}"
|
|
676
681
|
when /^(bright_)?(.+)$/
|
|
677
|
-
base_name =
|
|
682
|
+
base_name = ::Regexp.last_match(2)
|
|
678
683
|
index = SGR_16_TO_NAME.key(base_name)
|
|
679
|
-
index += 8 if
|
|
684
|
+
index += 8 if ::Regexp.last_match(1) && index && index < 8
|
|
680
685
|
index ? "#{prefix};5;#{index}" : nil
|
|
681
|
-
else
|
|
682
|
-
nil
|
|
683
686
|
end
|
|
684
687
|
end
|
|
688
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
685
689
|
|
|
686
690
|
def self.default_cell
|
|
687
691
|
{ char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
|
|
@@ -689,26 +693,28 @@ module TUITD
|
|
|
689
693
|
|
|
690
694
|
# Extract a single UTF-8 character at position i in a binary string.
|
|
691
695
|
# Returns [char_string, byte_length] or nil if the byte is not printable/valid.
|
|
696
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
|
|
692
697
|
def self._utf8_char_at(str, i)
|
|
693
698
|
byte = str.getbyte(i)
|
|
694
699
|
return nil unless byte
|
|
695
700
|
|
|
696
701
|
if byte < 0x80
|
|
697
702
|
# Single-byte ASCII
|
|
698
|
-
return nil unless byte >= 0x20
|
|
703
|
+
return nil unless byte >= 0x20 # only printable, skip control chars
|
|
704
|
+
|
|
699
705
|
return [byte.chr, 1]
|
|
700
706
|
end
|
|
701
707
|
|
|
702
708
|
# Multi-byte UTF-8
|
|
703
709
|
len = if byte & 0xE0 == 0xC0
|
|
704
710
|
2
|
|
705
|
-
|
|
711
|
+
elsif byte & 0xF0 == 0xE0
|
|
706
712
|
3
|
|
707
|
-
|
|
713
|
+
elsif byte & 0xF8 == 0xF0
|
|
708
714
|
4
|
|
709
|
-
|
|
710
|
-
return nil
|
|
711
|
-
|
|
715
|
+
else
|
|
716
|
+
return nil # continuation byte or invalid — let main loop advance
|
|
717
|
+
end
|
|
712
718
|
return nil if i + len > str.bytesize
|
|
713
719
|
|
|
714
720
|
bytes = str.byteslice(i, len)
|
|
@@ -719,5 +725,7 @@ module TUITD
|
|
|
719
725
|
rescue StandardError
|
|
720
726
|
nil
|
|
721
727
|
end
|
|
728
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
|
|
722
729
|
end
|
|
730
|
+
# rubocop:enable Metrics/ModuleLength
|
|
723
731
|
end
|
data/lib/tui_td/ansi_utils.rb
CHANGED
|
@@ -5,22 +5,22 @@ module TUITD
|
|
|
5
5
|
# Used by Screenshot, HtmlRenderer, and other color-aware renderers.
|
|
6
6
|
module ANSIUtils
|
|
7
7
|
ANSI_RGB = {
|
|
8
|
-
"black"
|
|
9
|
-
"red"
|
|
10
|
-
"green"
|
|
11
|
-
"yellow"
|
|
12
|
-
"blue"
|
|
13
|
-
"magenta"
|
|
14
|
-
"cyan"
|
|
15
|
-
"white"
|
|
16
|
-
"bright_black"
|
|
17
|
-
"bright_red"
|
|
18
|
-
"bright_green"
|
|
8
|
+
"black" => [0x00, 0x00, 0x00],
|
|
9
|
+
"red" => [0xAA, 0x00, 0x00],
|
|
10
|
+
"green" => [0x00, 0xAA, 0x00],
|
|
11
|
+
"yellow" => [0xAA, 0x55, 0x00],
|
|
12
|
+
"blue" => [0x00, 0x00, 0xAA],
|
|
13
|
+
"magenta" => [0xAA, 0x00, 0xAA],
|
|
14
|
+
"cyan" => [0x00, 0xAA, 0xAA],
|
|
15
|
+
"white" => [0xAA, 0xAA, 0xAA],
|
|
16
|
+
"bright_black" => [0x55, 0x55, 0x55],
|
|
17
|
+
"bright_red" => [0xFF, 0x55, 0x55],
|
|
18
|
+
"bright_green" => [0x55, 0xFF, 0x55],
|
|
19
19
|
"bright_yellow" => [0xFF, 0xFF, 0x55],
|
|
20
|
-
"bright_blue"
|
|
21
|
-
"bright_magenta"=> [0xFF, 0x55, 0xFF],
|
|
22
|
-
"bright_cyan"
|
|
23
|
-
"bright_white"
|
|
20
|
+
"bright_blue" => [0x55, 0x55, 0xFF],
|
|
21
|
+
"bright_magenta" => [0xFF, 0x55, 0xFF],
|
|
22
|
+
"bright_cyan" => [0x55, 0xFF, 0xFF],
|
|
23
|
+
"bright_white" => [0xFF, 0xFF, 0xFF],
|
|
24
24
|
}.freeze
|
|
25
25
|
|
|
26
26
|
CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
|
|
@@ -39,17 +39,18 @@ module TUITD
|
|
|
39
39
|
when "default"
|
|
40
40
|
fallback
|
|
41
41
|
when /^#([0-9a-fA-F]{6})$/
|
|
42
|
-
[
|
|
42
|
+
[::Regexp.last_match(1)[0..1].to_i(16), ::Regexp.last_match(1)[2..3].to_i(16),
|
|
43
|
+
::Regexp.last_match(1)[4..5].to_i(16),]
|
|
43
44
|
when /\Acolor(\d+)\z/
|
|
44
|
-
xterm_256(
|
|
45
|
+
xterm_256(::Regexp.last_match(1).to_i)
|
|
45
46
|
when /\Abright_(.+)\z/
|
|
46
47
|
ANSI_RGB[name] || fallback
|
|
47
|
-
else
|
|
48
|
+
else # rubocop:disable Lint/DuplicateBranch
|
|
48
49
|
ANSI_RGB[name] || fallback
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
def xterm_256(index)
|
|
53
|
+
def xterm_256(index) # rubocop:disable Naming/VariableNumber
|
|
53
54
|
if index < 16
|
|
54
55
|
name = ANSI_INDEX[index]
|
|
55
56
|
ANSI_RGB[name] || DEFAULT_FG
|
|
@@ -59,7 +60,7 @@ module TUITD
|
|
|
59
60
|
b = CUBE[(index - 16) % 6]
|
|
60
61
|
[r, g, b]
|
|
61
62
|
else
|
|
62
|
-
v = 8 + (index - 232) * 10
|
|
63
|
+
v = 8 + ((index - 232) * 10)
|
|
63
64
|
[v, v, v]
|
|
64
65
|
end
|
|
65
66
|
end
|
|
@@ -67,6 +68,7 @@ module TUITD
|
|
|
67
68
|
def _dig(hash, *keys)
|
|
68
69
|
keys.each do |k|
|
|
69
70
|
return nil unless hash
|
|
71
|
+
|
|
70
72
|
hash = hash[k] || hash[k.to_s]
|
|
71
73
|
end
|
|
72
74
|
hash
|