tui-td 0.2.4 → 0.2.6
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 +15 -0
- data/README.md +26 -52
- data/lib/tui_td/ansi_parser.rb +312 -37
- data/lib/tui_td/ansi_utils.rb +75 -0
- data/lib/tui_td/driver.rb +1 -1
- data/lib/tui_td/html_renderer.rb +79 -72
- data/lib/tui_td/screenshot.rb +186 -67
- data/lib/tui_td/state.rb +11 -1
- data/lib/tui_td/test_runner.rb +141 -123
- data/lib/tui_td/version.rb +1 -1
- data/lib/tui_td.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2716bc85090a6430068ad8820f9736657a3f122571999e34c39529060f59d487
|
|
4
|
+
data.tar.gz: b73ff229c0396a87a5121b43ff81edf65be1f13de1aed58a7404cd263a0ba84e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be25c3cfca1b280dacc7a2b968c887de55fce953e1c9903dd725d1291b34918375771eec7324522407ef783c2fc9010f0ef69b2e7c5fccc142bf6b2bd1396f40
|
|
7
|
+
data.tar.gz: 0ee80ad5882c21763241f39c781a03a30180baf04707f7af5652bd913254b69c647efa59b06c57ff534b8b797a188adfaf40068f37956cad08b33a4cbc28cae0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 0.2.6
|
|
4
|
+
|
|
5
|
+
- ISO-2022 charset switching support (G0/G1 designators, Shift Out/In) with DEC Special Character & Line Drawing mapping
|
|
6
|
+
- SGR mouse reporting mode parsing (1000, 1002, 1003, 1006)
|
|
7
|
+
- Mouse reporting and cursor visibility/style reconstruction in build_frame
|
|
8
|
+
- Fixed `state_data` in `driver.rb` to unconditionally refresh terminal state instead of returning stale cache
|
|
9
|
+
- New `cursor_visible`, `cursor_style`, `mouse_mode`, `mouse_format` attributes on `State`
|
|
10
|
+
- HTML and screenshot styling for new cursor/mouse attributes
|
|
11
|
+
|
|
12
|
+
## 0.2.5
|
|
13
|
+
|
|
14
|
+
- MCP smoke test expanded: 20 → 54 assertions, covers all 10 tools plus error paths (88% server coverage)
|
|
15
|
+
- Extracted `ansi_utils.rb` — shared ANSI helpers used by parser, renderer, and screenshot
|
|
16
|
+
- New RSpec specs for `ansi_utils`, enhanced specs for `test_runner`, `html_renderer`, `matchers`, `state`, `ansi_parser`
|
|
17
|
+
|
|
3
18
|
## 0.2.4
|
|
4
19
|
|
|
5
20
|
- `assert_regex` JSON test step — match terminal output against a Ruby regex
|
data/README.md
CHANGED
|
@@ -1,57 +1,23 @@
|
|
|
1
1
|
# TUI Test Drive
|
|
2
2
|
|
|
3
|
-
*Like Playwright or Puppeteer, but built specifically for Terminal UIs and optimized for AI Coding Agents.*
|
|
4
|
-
|
|
5
3
|
Testing framework for Terminal User Interfaces (TUIs) with MCP support.
|
|
6
4
|
|
|
7
|
-
A Ruby library, but language-agnostic through its JSON test format and MCP server — use it from Python, JavaScript, Go, or any other programming language on Linux and macOS.
|
|
8
|
-
|
|
9
5
|
**tui-td** lets you:
|
|
10
6
|
1. Start a TUI application in a virtual terminal (PTY)
|
|
11
7
|
2. See the output — as structured JSON, plain text, PNG screenshots, or HTML renders
|
|
12
8
|
3. Send input — keystrokes, text, control sequences
|
|
13
9
|
4. Analyze output — find text, check colors, detect cursor position
|
|
14
10
|
5. Loop — adjust and retest without manual intervention
|
|
15
|
-
6. Integrate — works with any language via JSON test files or MCP
|
|
16
11
|
|
|
17
12
|
## Installation
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
**rbenv (recommended):**
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
# macOS
|
|
25
|
-
brew install rbenv ruby-build
|
|
26
|
-
echo 'eval "$(rbenv init - zsh)"' >> ~/.zshrc
|
|
27
|
-
|
|
28
|
-
# Linux
|
|
29
|
-
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
|
|
30
|
-
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
|
|
31
|
-
echo 'eval "$(~/.rbenv/bin/rbenv init - bash)"' >> ~/.bashrc
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
Then install Ruby 3 and activate it:
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
rbenv install 3.4.1
|
|
38
|
-
rbenv global 3.4.1
|
|
39
|
-
ruby --version # must show 3.0+
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
**Alternative — Homebrew (macOS):**
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
brew install ruby
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### 2. Install tui-td
|
|
14
|
+
Ruby 3.0+ is required. Install via [rbenv](https://github.com/rbenv/rbenv#installation) or `brew install ruby`.
|
|
49
15
|
|
|
50
16
|
```bash
|
|
51
17
|
gem install tui-td
|
|
52
18
|
```
|
|
53
19
|
|
|
54
|
-
|
|
20
|
+
Quick test:
|
|
55
21
|
|
|
56
22
|
```bash
|
|
57
23
|
tui-td capture "echo Hello World"
|
|
@@ -124,20 +90,20 @@ Interactive commands (drive mode):
|
|
|
124
90
|
exit Quit drive mode
|
|
125
91
|
|
|
126
92
|
Global options:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
93
|
+
-r, --rows N Terminal rows (default: 40)
|
|
94
|
+
-c, --cols N Terminal cols (default: 120)
|
|
95
|
+
-t, --timeout SECONDS Timeout in seconds (default: 30)
|
|
96
|
+
-C, --chdir PATH Working directory for the command
|
|
97
|
+
--screenshot PATH Save screenshot (e.g., output.png)
|
|
98
|
+
--html PATH Save HTML render (e.g., output.html)
|
|
99
|
+
--json Output state as compact JSON
|
|
100
|
+
--pretty Output state as pretty JSON
|
|
101
|
+
--text Output state as plain text table
|
|
102
|
+
-v, --verbose Show each test step as it runs
|
|
103
|
+
-l, --live Show terminal state after each step (screen-refresh)
|
|
104
|
+
-s, --step Pause after each test step for confirmation
|
|
105
|
+
--version Show version
|
|
106
|
+
-h, --help Show help
|
|
141
107
|
```
|
|
142
108
|
|
|
143
109
|
`tui-td --help` serves as the full CLI reference. `tui-td help test` shows all JSON test
|
|
@@ -271,12 +237,18 @@ tui-td test examples/echo_test.json
|
|
|
271
237
|
"rows": 24,
|
|
272
238
|
"cols": 80,
|
|
273
239
|
"timeout": 10,
|
|
240
|
+
"chdir": "/path/to/project",
|
|
241
|
+
"before_all": [
|
|
242
|
+
{ "start": "my_tui_app", "env": { "DATABASE_URL": "test://" } }
|
|
243
|
+
],
|
|
274
244
|
"steps": [
|
|
275
|
-
{ "start": "my_tui_app" },
|
|
276
245
|
{ "wait_for_text": "> " },
|
|
277
246
|
{ "send": "hello\n" },
|
|
278
247
|
{ "assert_text": "hello" },
|
|
279
|
-
{ "
|
|
248
|
+
{ "assert_regex": "hello|world" },
|
|
249
|
+
{ "assert_fg": [0, 0], "is": "cyan" }
|
|
250
|
+
],
|
|
251
|
+
"after_all": [
|
|
280
252
|
{ "close": true }
|
|
281
253
|
]
|
|
282
254
|
}
|
|
@@ -371,9 +343,11 @@ end
|
|
|
371
343
|
| Matcher | Usage |
|
|
372
344
|
|---------|-------|
|
|
373
345
|
| `have_text("...")` | Assert text is present on screen |
|
|
346
|
+
| `have_regex(/pattern/)` | Assert regex pattern matches anywhere |
|
|
374
347
|
| `have_fg("color").at(row, col)` | Assert foreground color at position |
|
|
375
348
|
| `have_bg("color").at(row, col)` | Assert background color at position |
|
|
376
349
|
| `have_style.at(row, col).with(bold: true, ...)` | Assert cell style |
|
|
350
|
+
| `have_exit_status(N)` | Assert the driver process exit status equals N |
|
|
377
351
|
|
|
378
352
|
## MCP Server — AI Integration
|
|
379
353
|
|
data/lib/tui_td/ansi_parser.rb
CHANGED
|
@@ -80,20 +80,73 @@ module TUITD
|
|
|
80
80
|
15 => "bright_white",
|
|
81
81
|
}.freeze
|
|
82
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
|
+
|
|
83
117
|
# Parse raw terminal output into a structured state Hash
|
|
84
118
|
def self.parse(raw, rows = 40, cols = 120)
|
|
85
119
|
grid = Array.new(rows) do
|
|
86
|
-
Array.new(cols)
|
|
87
|
-
{ char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false }
|
|
88
|
-
end
|
|
120
|
+
Array.new(cols) { default_cell.dup }
|
|
89
121
|
end
|
|
90
122
|
|
|
91
123
|
cursor = { row: 0, col: 0 }
|
|
92
|
-
attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false }
|
|
124
|
+
attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
|
|
93
125
|
saved_cursor = nil
|
|
94
|
-
scroll_region =
|
|
126
|
+
scroll_region = { top: 0, bottom: rows - 1 }
|
|
95
127
|
pending_dsr = false
|
|
96
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
|
+
|
|
97
150
|
# Strip everything before the last full clear (if any)
|
|
98
151
|
# to avoid accumulated garbage
|
|
99
152
|
processed = raw
|
|
@@ -103,12 +156,77 @@ module TUITD
|
|
|
103
156
|
if processed[i] == "\e" && processed[i + 1] == "["
|
|
104
157
|
# Find end of CSI sequence
|
|
105
158
|
j = i + 2
|
|
106
|
-
j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX
|
|
159
|
+
j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@\`fhlmnRrsuq]/)
|
|
107
160
|
seq = processed[i..j]
|
|
108
161
|
|
|
109
|
-
dsr = _apply_csi(seq, cursor, attrs, grid, rows, cols)
|
|
162
|
+
dsr, new_saved, action = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
110
163
|
pending_dsr ||= dsr
|
|
111
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
|
+
|
|
112
230
|
i = j + 1
|
|
113
231
|
elsif processed[i] == "\n" || processed[i] == "\r\n"
|
|
114
232
|
cursor[:row] += 1
|
|
@@ -127,12 +245,39 @@ module TUITD
|
|
|
127
245
|
elsif processed[i] == "\a"
|
|
128
246
|
# Bell — ignore
|
|
129
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
|
|
130
254
|
elsif processed[i] == "\e"
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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)
|
|
136
281
|
i += 3
|
|
137
282
|
else
|
|
138
283
|
i += 1
|
|
@@ -141,7 +286,12 @@ module TUITD
|
|
|
141
286
|
# Printable character (including multi-byte UTF-8)
|
|
142
287
|
if cursor[:row] < rows && cursor[:col] < cols
|
|
143
288
|
cell = grid[cursor[:row]][cursor[:col]]
|
|
144
|
-
|
|
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
|
|
145
295
|
cell.merge!(attrs)
|
|
146
296
|
cursor[:col] += 1
|
|
147
297
|
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
@@ -151,22 +301,39 @@ module TUITD
|
|
|
151
301
|
i += 1
|
|
152
302
|
end
|
|
153
303
|
|
|
154
|
-
# Handle scrolling
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 }
|
|
160
318
|
end
|
|
161
|
-
cursor[:row] =
|
|
319
|
+
cursor[:row] = region_bottom
|
|
162
320
|
end
|
|
163
321
|
end
|
|
164
322
|
|
|
165
323
|
{
|
|
166
324
|
size: { rows: rows, cols: cols },
|
|
167
|
-
cursor:
|
|
325
|
+
cursor: {
|
|
326
|
+
row: cursor[:row],
|
|
327
|
+
col: cursor[:col],
|
|
328
|
+
visible: cursor_visible,
|
|
329
|
+
style: cursor_style
|
|
330
|
+
},
|
|
168
331
|
rows: grid,
|
|
169
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
|
|
170
337
|
}
|
|
171
338
|
end
|
|
172
339
|
|
|
@@ -176,6 +343,8 @@ module TUITD
|
|
|
176
343
|
cols = state.dig(:size, :cols) || state["size"]["cols"]
|
|
177
344
|
grid = state[:rows] || state["rows"]
|
|
178
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
|
|
179
348
|
|
|
180
349
|
out = +""
|
|
181
350
|
out << "\e[0m"
|
|
@@ -189,11 +358,13 @@ module TUITD
|
|
|
189
358
|
bold = cell[:bold] || cell["bold"] || false
|
|
190
359
|
italic = cell[:italic] || cell["italic"] || false
|
|
191
360
|
underline = cell[:underline] || cell["underline"] || false
|
|
361
|
+
blink = cell[:blink] || cell["blink"] || false
|
|
192
362
|
|
|
193
363
|
codes = []
|
|
194
364
|
codes << "1" if bold
|
|
195
365
|
codes << "3" if italic
|
|
196
366
|
codes << "4" if underline
|
|
367
|
+
codes << "5" if blink
|
|
197
368
|
|
|
198
369
|
fg_code = _color_code(fg, "38")
|
|
199
370
|
bg_code = _color_code(bg, "48")
|
|
@@ -207,18 +378,53 @@ module TUITD
|
|
|
207
378
|
out << "\n" if ri < rows - 1
|
|
208
379
|
end
|
|
209
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
|
+
|
|
210
411
|
out << "\e[0m"
|
|
211
412
|
out
|
|
212
413
|
end
|
|
213
414
|
|
|
214
|
-
def self._apply_csi(seq, cursor, attrs, grid, rows, cols)
|
|
415
|
+
def self._apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
215
416
|
# Strip leading escape char if present
|
|
216
417
|
cleaned = seq.sub(/^\e/, "")
|
|
217
|
-
match = cleaned.match(/^\[([\d;]*)([A-HJ-KP-SX
|
|
218
|
-
return unless match
|
|
418
|
+
match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX@\`fhlmnRrsuq])$/)
|
|
419
|
+
return [false, nil, {}] unless match
|
|
219
420
|
|
|
220
|
-
|
|
221
|
-
|
|
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 = {}
|
|
222
428
|
|
|
223
429
|
case command
|
|
224
430
|
when "m"
|
|
@@ -270,37 +476,102 @@ module TUITD
|
|
|
270
476
|
next unless cursor[:row] < rows && cursor[:col] + i < cols
|
|
271
477
|
grid[cursor[:row]][cursor[:col] + i][:char] = " "
|
|
272
478
|
end
|
|
273
|
-
when "
|
|
274
|
-
|
|
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
|
|
275
541
|
when "n" # DSR — Device Status Report request
|
|
276
|
-
|
|
277
|
-
return params[0] == 6
|
|
542
|
+
return [params[0] == 6, nil, {}]
|
|
278
543
|
when "R" # DSR response (from terminal side) or CPR — ignore
|
|
279
544
|
nil
|
|
280
545
|
end
|
|
546
|
+
|
|
547
|
+
[false, new_saved, action]
|
|
281
548
|
end
|
|
282
549
|
|
|
283
550
|
def self._apply_sgr(params, attrs)
|
|
284
|
-
return attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false) if params.empty? || params == [0]
|
|
551
|
+
return attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false) if params.empty? || params == [0]
|
|
285
552
|
|
|
286
553
|
i = 0
|
|
287
554
|
while i < params.length
|
|
288
555
|
p = params[i]
|
|
289
556
|
case p
|
|
290
557
|
when 0
|
|
291
|
-
attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false)
|
|
558
|
+
attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false)
|
|
292
559
|
when 1
|
|
293
560
|
attrs[:bold] = true
|
|
294
561
|
when 3
|
|
295
562
|
attrs[:italic] = true
|
|
296
563
|
when 4
|
|
297
564
|
attrs[:underline] = true
|
|
565
|
+
when 5, 6
|
|
566
|
+
attrs[:blink] = true
|
|
298
567
|
when 22
|
|
299
568
|
attrs[:bold] = false
|
|
300
569
|
when 23
|
|
301
570
|
attrs[:italic] = false
|
|
302
571
|
when 24
|
|
303
572
|
attrs[:underline] = false
|
|
573
|
+
when 25
|
|
574
|
+
attrs[:blink] = false
|
|
304
575
|
when 7
|
|
305
576
|
# Reverse — swap fg and bg
|
|
306
577
|
attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
|
|
@@ -397,21 +668,25 @@ module TUITD
|
|
|
397
668
|
def self._color_code(name, prefix)
|
|
398
669
|
case name
|
|
399
670
|
when "default" then nil
|
|
400
|
-
when /^(bright_)?(.+)$/
|
|
401
|
-
base_name = $2
|
|
402
|
-
index = SGR_16_TO_NAME.key(base_name)
|
|
403
|
-
index += 8 if $1 && index && index < 8
|
|
404
|
-
index ? "#{prefix};5;#{index}" : nil
|
|
405
671
|
when /^#([0-9a-fA-F]{6})$/
|
|
406
672
|
r = $1[0..1].to_i(16)
|
|
407
673
|
g = $1[2..3].to_i(16)
|
|
408
674
|
b = $1[4..5].to_i(16)
|
|
409
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
|
|
410
681
|
else
|
|
411
682
|
nil
|
|
412
683
|
end
|
|
413
684
|
end
|
|
414
685
|
|
|
686
|
+
def self.default_cell
|
|
687
|
+
{ char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
|
|
688
|
+
end
|
|
689
|
+
|
|
415
690
|
# Extract a single UTF-8 character at position i in a binary string.
|
|
416
691
|
# Returns [char_string, byte_length] or nil if the byte is not printable/valid.
|
|
417
692
|
def self._utf8_char_at(str, i)
|