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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97ed62eebe255ca568235b0ffe8bb428812d0dbc0408e24e768d76e8b11beb8a
4
- data.tar.gz: 8b66334f5408398dab8dcf02535e8473c2d9af7357f4fc1db45bc67ca792706c
3
+ metadata.gz: 2716bc85090a6430068ad8820f9736657a3f122571999e34c39529060f59d487
4
+ data.tar.gz: b73ff229c0396a87a5121b43ff81edf65be1f13de1aed58a7404cd263a0ba84e
5
5
  SHA512:
6
- metadata.gz: 404c5b2c0c411b25846855d0d5ad297493be90672ee3eba53980df429ec1263170b605d023983a4fbc17c2b4563b10ff91c98324a08651cf0997d4a9cde21667
7
- data.tar.gz: 5b73ca7cccaa81b434ca418e5f246573033ac5abd6c18b7094adad6f65d8fa453be2e8c84d842f2ba82e182a9189989a2389599874e1ab9448606db30677d901
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
- ### 1. Install Ruby
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
- ### 3. Test
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
- -r, --rows N Terminal rows (default: 40)
128
- -c, --cols N Terminal cols (default: 120)
129
- -t, --timeout N Timeout in seconds (default: 30)
130
- -C, --chdir PATH Working directory for the command
131
- --screenshot PATH Save PNG screenshot
132
- --html PATH Save HTML render for browser viewing
133
- --json Output state as compact JSON (includes raw ANSI)
134
- --pretty Output state as pretty JSON
135
- --text Output state as plain text table
136
- -v, --verbose Show each test step as it runs
137
- -l, --live Show terminal state after each test step (screen-refresh)
138
- -s, --step Pause after each test step for confirmation
139
- --version Show version
140
- -h, --help Show complete reference
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
- { "assert_fg": [0, 0], "is": "cyan" },
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
 
@@ -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) do
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 = nil
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@`fhlmnR]/)
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
- # Skip escape sequences:
132
- # CSI: \e[... (already handled above)
133
- # ISO 2022 charset: \e( B \e) 0 etc. (3 chars total)
134
- # Other: just the ESC
135
- if processed[i + 1] && processed[i + 1].match?(/[()*+\-.\/]/)
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
- cell[:char] = char
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
- if cursor[:row] >= rows
156
- scroll_lines = cursor[:row] - rows + 1
157
- grid.shift(scroll_lines)
158
- scroll_lines.times do
159
- grid << Array.new(cols) { { char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false } }
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] = rows - 1
319
+ cursor[:row] = region_bottom
162
320
  end
163
321
  end
164
322
 
165
323
  {
166
324
  size: { rows: rows, cols: cols },
167
- cursor: 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@`fhlmnR])$/)
218
- return unless match
418
+ match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX@\`fhlmnRrsuq])$/)
419
+ return [false, nil, {}] unless match
219
420
 
220
- params = match[1].split(";").map(&:to_i)
221
- command = match[2]
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 "h", "l" # DEC private mode set/reset — skip (alternate screen, cursor show/hide, etc.)
274
- nil
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
- # \e[6n = request cursor position → caller must respond with \e[row;colR
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)