tui-td 0.2.5 → 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 +9 -0
- data/lib/tui_td/ansi_parser.rb +250 -19
- data/lib/tui_td/driver.rb +1 -1
- data/lib/tui_td/html_renderer.rb +76 -6
- data/lib/tui_td/screenshot.rb +186 -0
- data/lib/tui_td/state.rb +8 -1
- data/lib/tui_td/version.rb +1 -1
- metadata +1 -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,14 @@
|
|
|
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
|
+
|
|
3
12
|
## 0.2.5
|
|
4
13
|
|
|
5
14
|
- MCP smoke test expanded: 20 → 54 assertions, covers all 10 tools plus error paths (88% server coverage)
|
data/lib/tui_td/ansi_parser.rb
CHANGED
|
@@ -80,6 +80,40 @@ 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
|
|
@@ -87,11 +121,32 @@ module TUITD
|
|
|
87
121
|
end
|
|
88
122
|
|
|
89
123
|
cursor = { row: 0, col: 0 }
|
|
90
|
-
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 }
|
|
91
125
|
saved_cursor = nil
|
|
92
126
|
scroll_region = { top: 0, bottom: rows - 1 }
|
|
93
127
|
pending_dsr = false
|
|
94
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
|
+
|
|
95
150
|
# Strip everything before the last full clear (if any)
|
|
96
151
|
# to avoid accumulated garbage
|
|
97
152
|
processed = raw
|
|
@@ -101,12 +156,76 @@ module TUITD
|
|
|
101
156
|
if processed[i] == "\e" && processed[i + 1] == "["
|
|
102
157
|
# Find end of CSI sequence
|
|
103
158
|
j = i + 2
|
|
104
|
-
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]/)
|
|
105
160
|
seq = processed[i..j]
|
|
106
161
|
|
|
107
|
-
dsr, new_saved = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
162
|
+
dsr, new_saved, action = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
108
163
|
pending_dsr ||= dsr
|
|
109
|
-
|
|
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
|
|
110
229
|
|
|
111
230
|
i = j + 1
|
|
112
231
|
elsif processed[i] == "\n" || processed[i] == "\r\n"
|
|
@@ -126,11 +245,23 @@ module TUITD
|
|
|
126
245
|
elsif processed[i] == "\a"
|
|
127
246
|
# Bell — ignore
|
|
128
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
|
|
129
254
|
elsif processed[i] == "\e"
|
|
130
255
|
# Handle non-CSI escape sequences
|
|
131
256
|
if processed[i + 1] == "7"
|
|
132
257
|
# DECSC — Save Cursor
|
|
133
|
-
|
|
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
|
|
134
265
|
i += 2
|
|
135
266
|
elsif processed[i + 1] == "8"
|
|
136
267
|
# DECRC — Restore Cursor
|
|
@@ -139,8 +270,14 @@ module TUITD
|
|
|
139
270
|
cursor[:col] = saved_cursor[:col]
|
|
140
271
|
end
|
|
141
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
|
|
142
279
|
elsif processed[i + 1] && processed[i + 1].match?(/[()*+\-.\/]/)
|
|
143
|
-
# ISO 2022 charset
|
|
280
|
+
# Other ISO 2022 charset sequences (e.g. G2/G3 or other charsets)
|
|
144
281
|
i += 3
|
|
145
282
|
else
|
|
146
283
|
i += 1
|
|
@@ -149,7 +286,12 @@ module TUITD
|
|
|
149
286
|
# Printable character (including multi-byte UTF-8)
|
|
150
287
|
if cursor[:row] < rows && cursor[:col] < cols
|
|
151
288
|
cell = grid[cursor[:row]][cursor[:col]]
|
|
152
|
-
|
|
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
|
|
153
295
|
cell.merge!(attrs)
|
|
154
296
|
cursor[:col] += 1
|
|
155
297
|
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
@@ -180,9 +322,18 @@ module TUITD
|
|
|
180
322
|
|
|
181
323
|
{
|
|
182
324
|
size: { rows: rows, cols: cols },
|
|
183
|
-
cursor:
|
|
325
|
+
cursor: {
|
|
326
|
+
row: cursor[:row],
|
|
327
|
+
col: cursor[:col],
|
|
328
|
+
visible: cursor_visible,
|
|
329
|
+
style: cursor_style
|
|
330
|
+
},
|
|
184
331
|
rows: grid,
|
|
185
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
|
|
186
337
|
}
|
|
187
338
|
end
|
|
188
339
|
|
|
@@ -192,6 +343,8 @@ module TUITD
|
|
|
192
343
|
cols = state.dig(:size, :cols) || state["size"]["cols"]
|
|
193
344
|
grid = state[:rows] || state["rows"]
|
|
194
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
|
|
195
348
|
|
|
196
349
|
out = +""
|
|
197
350
|
out << "\e[0m"
|
|
@@ -205,11 +358,13 @@ module TUITD
|
|
|
205
358
|
bold = cell[:bold] || cell["bold"] || false
|
|
206
359
|
italic = cell[:italic] || cell["italic"] || false
|
|
207
360
|
underline = cell[:underline] || cell["underline"] || false
|
|
361
|
+
blink = cell[:blink] || cell["blink"] || false
|
|
208
362
|
|
|
209
363
|
codes = []
|
|
210
364
|
codes << "1" if bold
|
|
211
365
|
codes << "3" if italic
|
|
212
366
|
codes << "4" if underline
|
|
367
|
+
codes << "5" if blink
|
|
213
368
|
|
|
214
369
|
fg_code = _color_code(fg, "38")
|
|
215
370
|
bg_code = _color_code(bg, "48")
|
|
@@ -223,6 +378,36 @@ module TUITD
|
|
|
223
378
|
out << "\n" if ri < rows - 1
|
|
224
379
|
end
|
|
225
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
|
+
|
|
226
411
|
out << "\e[0m"
|
|
227
412
|
out
|
|
228
413
|
end
|
|
@@ -230,13 +415,16 @@ module TUITD
|
|
|
230
415
|
def self._apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
231
416
|
# Strip leading escape char if present
|
|
232
417
|
cleaned = seq.sub(/^\e/, "")
|
|
233
|
-
match = cleaned.match(/^\[([\d;]*)([A-HJ-KP-SX
|
|
234
|
-
return [false, nil] unless match
|
|
418
|
+
match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX@\`fhlmnRrsuq])$/)
|
|
419
|
+
return [false, nil, {}] unless match
|
|
235
420
|
|
|
236
|
-
|
|
237
|
-
|
|
421
|
+
is_private = (match[1] == "?")
|
|
422
|
+
params = match[2].split(";").map(&:to_i)
|
|
423
|
+
space = match[3]
|
|
424
|
+
command = match[4]
|
|
238
425
|
|
|
239
426
|
new_saved = nil
|
|
427
|
+
action = {}
|
|
240
428
|
|
|
241
429
|
case command
|
|
242
430
|
when "m"
|
|
@@ -309,38 +497,81 @@ module TUITD
|
|
|
309
497
|
end
|
|
310
498
|
cursor[:row] = 0
|
|
311
499
|
cursor[:col] = 0
|
|
312
|
-
when "h"
|
|
313
|
-
|
|
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
|
|
314
541
|
when "n" # DSR — Device Status Report request
|
|
315
|
-
return [params[0] == 6, nil]
|
|
542
|
+
return [params[0] == 6, nil, {}]
|
|
316
543
|
when "R" # DSR response (from terminal side) or CPR — ignore
|
|
317
544
|
nil
|
|
318
545
|
end
|
|
319
546
|
|
|
320
|
-
[false, new_saved]
|
|
547
|
+
[false, new_saved, action]
|
|
321
548
|
end
|
|
322
549
|
|
|
323
550
|
def self._apply_sgr(params, attrs)
|
|
324
|
-
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]
|
|
325
552
|
|
|
326
553
|
i = 0
|
|
327
554
|
while i < params.length
|
|
328
555
|
p = params[i]
|
|
329
556
|
case p
|
|
330
557
|
when 0
|
|
331
|
-
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)
|
|
332
559
|
when 1
|
|
333
560
|
attrs[:bold] = true
|
|
334
561
|
when 3
|
|
335
562
|
attrs[:italic] = true
|
|
336
563
|
when 4
|
|
337
564
|
attrs[:underline] = true
|
|
565
|
+
when 5, 6
|
|
566
|
+
attrs[:blink] = true
|
|
338
567
|
when 22
|
|
339
568
|
attrs[:bold] = false
|
|
340
569
|
when 23
|
|
341
570
|
attrs[:italic] = false
|
|
342
571
|
when 24
|
|
343
572
|
attrs[:underline] = false
|
|
573
|
+
when 25
|
|
574
|
+
attrs[:blink] = false
|
|
344
575
|
when 7
|
|
345
576
|
# Reverse — swap fg and bg
|
|
346
577
|
attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
|
|
@@ -453,7 +684,7 @@ module TUITD
|
|
|
453
684
|
end
|
|
454
685
|
|
|
455
686
|
def self.default_cell
|
|
456
|
-
{ char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false }
|
|
687
|
+
{ char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
|
|
457
688
|
end
|
|
458
689
|
|
|
459
690
|
# Extract a single UTF-8 character at position i in a binary string.
|
data/lib/tui_td/driver.rb
CHANGED
data/lib/tui_td/html_renderer.rb
CHANGED
|
@@ -72,6 +72,49 @@ module TUITD
|
|
|
72
72
|
z-index: 1;
|
|
73
73
|
position: relative;
|
|
74
74
|
}
|
|
75
|
+
.cursor-cell.cursor-hidden {
|
|
76
|
+
outline: none !important;
|
|
77
|
+
border: none !important;
|
|
78
|
+
background-color: transparent !important;
|
|
79
|
+
color: inherit !important;
|
|
80
|
+
}
|
|
81
|
+
.cursor-cell.cursor-block {
|
|
82
|
+
outline: none;
|
|
83
|
+
background-color: #fff;
|
|
84
|
+
color: #000 !important;
|
|
85
|
+
}
|
|
86
|
+
.cursor-cell.cursor-block.blink {
|
|
87
|
+
animation: cursor-block-blink 1s step-end infinite;
|
|
88
|
+
}
|
|
89
|
+
.cursor-cell.cursor-underline {
|
|
90
|
+
outline: none;
|
|
91
|
+
border-bottom: 2px solid #fff;
|
|
92
|
+
}
|
|
93
|
+
.cursor-cell.cursor-underline.blink {
|
|
94
|
+
animation: cursor-underline-blink 1s step-end infinite;
|
|
95
|
+
}
|
|
96
|
+
.cursor-cell.cursor-bar {
|
|
97
|
+
outline: none;
|
|
98
|
+
border-left: 2px solid #fff;
|
|
99
|
+
}
|
|
100
|
+
.cursor-cell.cursor-bar.blink {
|
|
101
|
+
animation: cursor-bar-blink 1s step-end infinite;
|
|
102
|
+
}
|
|
103
|
+
@keyframes cursor-block-blink {
|
|
104
|
+
50% { background-color: transparent; color: inherit; }
|
|
105
|
+
}
|
|
106
|
+
@keyframes cursor-underline-blink {
|
|
107
|
+
50% { border-bottom-color: transparent; }
|
|
108
|
+
}
|
|
109
|
+
@keyframes cursor-bar-blink {
|
|
110
|
+
50% { border-left-color: transparent; }
|
|
111
|
+
}
|
|
112
|
+
@keyframes term-blink {
|
|
113
|
+
50% { opacity: 0; }
|
|
114
|
+
}
|
|
115
|
+
.term-blink {
|
|
116
|
+
animation: term-blink 1s step-end infinite;
|
|
117
|
+
}
|
|
75
118
|
CSS
|
|
76
119
|
end
|
|
77
120
|
|
|
@@ -103,17 +146,20 @@ module TUITD
|
|
|
103
146
|
bold = cell[:bold] || cell["bold"] || false
|
|
104
147
|
italic = cell[:italic] || cell["italic"] || false
|
|
105
148
|
underline = cell[:underline] || cell["underline"] || false
|
|
149
|
+
blink = cell[:blink] || cell["blink"] || false
|
|
106
150
|
|
|
107
|
-
style_key = [fg, bg, bold, italic, underline]
|
|
151
|
+
style_key = [fg, bg, bold, italic, underline, blink]
|
|
152
|
+
is_cur = is_cursor?(ri, ci)
|
|
108
153
|
|
|
109
|
-
if current_run && current_run[:key] == style_key
|
|
154
|
+
if current_run && current_run[:key] == style_key && !current_run[:has_cursor] && !is_cur
|
|
110
155
|
current_run[:chars] << char
|
|
111
156
|
else
|
|
112
157
|
current_run = {
|
|
113
158
|
key: style_key,
|
|
114
159
|
chars: [char],
|
|
115
160
|
style: cell_style(fg, bg, bold, italic, underline),
|
|
116
|
-
has_cursor:
|
|
161
|
+
has_cursor: is_cur,
|
|
162
|
+
blink: blink
|
|
117
163
|
}
|
|
118
164
|
runs << current_run
|
|
119
165
|
end
|
|
@@ -134,17 +180,41 @@ module TUITD
|
|
|
134
180
|
|
|
135
181
|
def render_run(run)
|
|
136
182
|
chars = run[:chars].map { |c| escape_html(c) }.join
|
|
137
|
-
return chars if run[:style].empty? && !run[:has_cursor]
|
|
183
|
+
return chars if run[:style].empty? && !run[:has_cursor] && !run[:blink]
|
|
138
184
|
|
|
139
185
|
classes = []
|
|
140
|
-
|
|
186
|
+
if run[:has_cursor]
|
|
187
|
+
classes << "cursor-cell"
|
|
188
|
+
cursor_vis = @cursor[:visible] != false && @cursor["visible"] != false
|
|
189
|
+
if !cursor_vis
|
|
190
|
+
classes << "cursor-hidden"
|
|
191
|
+
else
|
|
192
|
+
style_val = @cursor[:style] || @cursor["style"]
|
|
193
|
+
case style_val
|
|
194
|
+
when 0, 1
|
|
195
|
+
classes << "cursor-block blink"
|
|
196
|
+
when 2
|
|
197
|
+
classes << "cursor-block"
|
|
198
|
+
when 3
|
|
199
|
+
classes << "cursor-underline blink"
|
|
200
|
+
when 4
|
|
201
|
+
classes << "cursor-underline"
|
|
202
|
+
when 5
|
|
203
|
+
classes << "cursor-bar blink"
|
|
204
|
+
when 6
|
|
205
|
+
classes << "cursor-bar"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
classes << "term-blink" if run[:blink]
|
|
210
|
+
|
|
141
211
|
cls = classes.empty? ? "" : %( class="#{classes.join(" ")}")
|
|
142
212
|
style = run[:style].empty? ? "" : %( style="#{run[:style]}")
|
|
143
213
|
%(<span#{cls}#{style}>#{chars}</span>)
|
|
144
214
|
end
|
|
145
215
|
|
|
146
216
|
def is_cursor?(ri, ci)
|
|
147
|
-
@cursor[:row] == ri && @cursor[:col] == ci
|
|
217
|
+
(@cursor[:row] || @cursor["row"]) == ri && (@cursor[:col] || @cursor["col"]) == ci
|
|
148
218
|
end
|
|
149
219
|
|
|
150
220
|
def css_color(rgb)
|
data/lib/tui_td/screenshot.rb
CHANGED
|
@@ -107,6 +107,97 @@ module TUITD
|
|
|
107
107
|
0x00, 0x70, 0x18, 0x18, 0x18, 0x18, 0x0e, 0x0e, 0x18, 0x18, 0x18, 0x18, 0x70, 0x00, 0x00, 0x00, # } (125)
|
|
108
108
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x32, 0x7e, 0x4c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # ~ (126)
|
|
109
109
|
].freeze
|
|
110
|
+
BOX_CHARS = {
|
|
111
|
+
# horizontal
|
|
112
|
+
"─" => [false, false, true, true, :light],
|
|
113
|
+
"━" => [false, false, true, true, :heavy],
|
|
114
|
+
"═" => [false, false, true, true, :double],
|
|
115
|
+
# vertical
|
|
116
|
+
"│" => [true, true, false, false, :light],
|
|
117
|
+
"┃" => [true, true, false, false, :heavy],
|
|
118
|
+
"║" => [true, true, false, false, :double],
|
|
119
|
+
# corners
|
|
120
|
+
"┌" => [false, true, false, true, :light],
|
|
121
|
+
"┍" => [false, true, false, true, :light],
|
|
122
|
+
"┎" => [false, true, false, true, :light],
|
|
123
|
+
"┏" => [false, true, false, true, :heavy],
|
|
124
|
+
"┐" => [false, true, true, false, :light],
|
|
125
|
+
"┑" => [false, true, true, false, :light],
|
|
126
|
+
"┒" => [false, true, true, false, :light],
|
|
127
|
+
"┓" => [false, true, true, false, :heavy],
|
|
128
|
+
"└" => [true, false, false, true, :light],
|
|
129
|
+
"▼" => [true, false, false, true, :light],
|
|
130
|
+
"┖" => [true, false, false, true, :light],
|
|
131
|
+
"┗" => [true, false, false, true, :heavy],
|
|
132
|
+
"┘" => [true, false, true, false, :light],
|
|
133
|
+
"┙" => [true, false, true, false, :light],
|
|
134
|
+
"┚" => [true, false, true, false, :light],
|
|
135
|
+
"┛" => [true, false, true, false, :heavy],
|
|
136
|
+
# double corners
|
|
137
|
+
"╔" => [false, true, false, true, :double],
|
|
138
|
+
"╗" => [false, true, true, false, :double],
|
|
139
|
+
"╚" => [true, false, false, true, :double],
|
|
140
|
+
"╝" => [true, false, true, false, :double],
|
|
141
|
+
# T-junctions
|
|
142
|
+
"├" => [true, true, false, true, :light],
|
|
143
|
+
"┣" => [true, true, false, true, :heavy],
|
|
144
|
+
"┤" => [true, true, true, false, :light],
|
|
145
|
+
"┫" => [true, true, true, false, :heavy],
|
|
146
|
+
"┬" => [false, true, true, true, :light],
|
|
147
|
+
"┳" => [false, true, true, true, :heavy],
|
|
148
|
+
"┴" => [true, false, true, true, :light],
|
|
149
|
+
"┻" => [true, false, true, true, :heavy],
|
|
150
|
+
# double T-junctions
|
|
151
|
+
"╠" => [true, true, false, true, :double],
|
|
152
|
+
"╣" => [true, true, true, false, :double],
|
|
153
|
+
"╦" => [false, true, true, true, :double],
|
|
154
|
+
"╩" => [true, false, true, true, :double],
|
|
155
|
+
# crosses
|
|
156
|
+
"┼" => [true, true, true, true, :light],
|
|
157
|
+
"╋" => [true, true, true, true, :heavy],
|
|
158
|
+
"╬" => [true, true, true, true, :double],
|
|
159
|
+
# single lines (ends)
|
|
160
|
+
"╴" => [false, false, true, false, :light],
|
|
161
|
+
"╵" => [true, false, false, false, :light],
|
|
162
|
+
"╶" => [false, false, false, true, :light],
|
|
163
|
+
"╷" => [false, true, false, false, :light],
|
|
164
|
+
"╸" => [false, false, true, false, :heavy],
|
|
165
|
+
"╹" => [true, false, false, false, :heavy],
|
|
166
|
+
"╺" => [false, false, false, true, :heavy],
|
|
167
|
+
"╻" => [false, true, false, false, :heavy],
|
|
168
|
+
# mixed corners/junctions
|
|
169
|
+
"┿" => [true, true, true, true, :light],
|
|
170
|
+
"╀" => [true, true, true, true, :light],
|
|
171
|
+
"╁" => [true, true, true, true, :light],
|
|
172
|
+
"╂" => [true, true, true, true, :light],
|
|
173
|
+
"╃" => [true, true, true, true, :heavy],
|
|
174
|
+
"╄" => [true, true, true, true, :heavy],
|
|
175
|
+
"╅" => [true, true, true, true, :heavy],
|
|
176
|
+
"╆" => [true, true, true, true, :heavy],
|
|
177
|
+
"╇" => [true, true, true, true, :heavy],
|
|
178
|
+
"╈" => [true, true, true, true, :heavy],
|
|
179
|
+
"╉" => [true, true, true, true, :heavy],
|
|
180
|
+
"╊" => [true, true, true, true, :heavy],
|
|
181
|
+
"╒" => [false, true, false, true, :double],
|
|
182
|
+
"╓" => [false, true, false, true, :double],
|
|
183
|
+
"╕" => [false, true, true, false, :double],
|
|
184
|
+
"╖" => [false, true, true, false, :double],
|
|
185
|
+
"╘" => [true, false, false, true, :double],
|
|
186
|
+
"╙" => [true, false, false, true, :double],
|
|
187
|
+
"╛" => [true, false, true, false, :double],
|
|
188
|
+
"╜" => [true, false, true, false, :double],
|
|
189
|
+
"╞" => [true, true, false, true, :double],
|
|
190
|
+
"╟" => [true, true, false, true, :double],
|
|
191
|
+
"╡" => [true, true, true, false, :double],
|
|
192
|
+
"╢" => [true, true, true, false, :double],
|
|
193
|
+
"╤" => [false, true, true, true, :double],
|
|
194
|
+
"╥" => [false, true, true, true, :double],
|
|
195
|
+
"╧" => [true, false, true, true, :double],
|
|
196
|
+
"╨" => [true, false, true, true, :double],
|
|
197
|
+
"╪" => [true, true, true, true, :double],
|
|
198
|
+
"╫" => [true, true, true, true, :double]
|
|
199
|
+
}.freeze
|
|
200
|
+
|
|
110
201
|
private_constant :FONT
|
|
111
202
|
|
|
112
203
|
def initialize(state)
|
|
@@ -151,6 +242,12 @@ module TUITD
|
|
|
151
242
|
|
|
152
243
|
fill_rect(image, px, py, CELL_W, CELL_H, bg_rgb)
|
|
153
244
|
|
|
245
|
+
if box_drawing?(char)
|
|
246
|
+
draw_box_character(image, px, py, char, fg_rgb)
|
|
247
|
+
draw_underline(image, px, py, CELL_W, fg_rgb) if underline
|
|
248
|
+
return
|
|
249
|
+
end
|
|
250
|
+
|
|
154
251
|
return if char == " " || char.ord < 32 || char.ord > 126
|
|
155
252
|
|
|
156
253
|
rows_data = glyph_rows(char)
|
|
@@ -200,5 +297,94 @@ module TUITD
|
|
|
200
297
|
w.times { |dx| image[px + dx, y] = color }
|
|
201
298
|
end
|
|
202
299
|
|
|
300
|
+
def box_drawing?(char)
|
|
301
|
+
char_ord = char.ord
|
|
302
|
+
char_ord >= 0x2500 && char_ord <= 0x257F
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def draw_box_character(image, px, py, char, fg_rgb)
|
|
306
|
+
config = BOX_CHARS[char]
|
|
307
|
+
|
|
308
|
+
unless config
|
|
309
|
+
char_ord = char.ord
|
|
310
|
+
if [0x2500, 0x2501, 0x2504, 0x2505, 0x2508, 0x2509, 0x254c, 0x254d, 0x2550].include?(char_ord)
|
|
311
|
+
style = [0x2501, 0x2505, 0x2509, 0x254d].include?(char_ord) ? :heavy : (char_ord == 0x2550 ? :double : :light)
|
|
312
|
+
config = [false, false, true, true, style]
|
|
313
|
+
elsif [0x2502, 0x2503, 0x2506, 0x2507, 0x250a, 0x250b, 0x254e, 0x254f, 0x2551].include?(char_ord)
|
|
314
|
+
style = [0x2503, 0x2507, 0x250b, 0x254f].include?(char_ord) ? :heavy : (char_ord == 0x2551 ? :double : :light)
|
|
315
|
+
config = [true, true, false, false, style]
|
|
316
|
+
else
|
|
317
|
+
config = [true, true, true, true, :light]
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
up, down, left, right, style = config
|
|
322
|
+
cx = px + 4
|
|
323
|
+
cy = py + 8
|
|
324
|
+
|
|
325
|
+
color = ChunkyPNG::Color.rgb(*fg_rgb)
|
|
326
|
+
|
|
327
|
+
if style == :double
|
|
328
|
+
if left
|
|
329
|
+
(px..(cx + 2)).each { |x| image[x, py + 6] = color }
|
|
330
|
+
(px..(cx + 2)).each { |x| image[x, py + 10] = color }
|
|
331
|
+
end
|
|
332
|
+
if right
|
|
333
|
+
((cx - 2)..(px + 7)).each { |x| image[x, py + 6] = color }
|
|
334
|
+
((cx - 2)..(px + 7)).each { |x| image[x, py + 10] = color }
|
|
335
|
+
end
|
|
336
|
+
if up
|
|
337
|
+
(py..(cy + 2)).each { |y| image[px + 2, y] = color }
|
|
338
|
+
(py..(cy + 2)).each { |y| image[px + 6, y] = color }
|
|
339
|
+
end
|
|
340
|
+
if down
|
|
341
|
+
((cy - 2)..(py + 15)).each { |y| image[px + 2, y] = color }
|
|
342
|
+
((cy - 2)..(py + 15)).each { |y| image[px + 6, y] = color }
|
|
343
|
+
end
|
|
344
|
+
elsif style == :heavy
|
|
345
|
+
if left
|
|
346
|
+
(px..cx).each do |x|
|
|
347
|
+
image[x, cy - 1] = color
|
|
348
|
+
image[x, cy] = color
|
|
349
|
+
image[x, cy + 1] = color
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
if right
|
|
353
|
+
(cx..(px + 7)).each do |x|
|
|
354
|
+
image[x, cy - 1] = color
|
|
355
|
+
image[x, cy] = color
|
|
356
|
+
image[x, cy + 1] = color
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
if up
|
|
360
|
+
(py..cy).each do |y|
|
|
361
|
+
image[cx - 1, y] = color
|
|
362
|
+
image[cx, y] = color
|
|
363
|
+
image[cx + 1, y] = color
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
if down
|
|
367
|
+
(cy..(py + 15)).each do |y|
|
|
368
|
+
image[cx - 1, y] = color
|
|
369
|
+
image[cx, y] = color
|
|
370
|
+
image[cx + 1, y] = color
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
else # :light
|
|
374
|
+
if left
|
|
375
|
+
(px..cx).each { |x| image[x, cy] = color }
|
|
376
|
+
end
|
|
377
|
+
if right
|
|
378
|
+
(cx..(px + 7)).each { |x| image[x, cy] = color }
|
|
379
|
+
end
|
|
380
|
+
if up
|
|
381
|
+
(py..cy).each { |y| image[cx, y] = color }
|
|
382
|
+
end
|
|
383
|
+
if down
|
|
384
|
+
(cy..(py + 15)).each { |y| image[cx, y] = color }
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
203
389
|
end
|
|
204
390
|
end
|
data/lib/tui_td/state.rb
CHANGED
|
@@ -4,7 +4,7 @@ module TUITD
|
|
|
4
4
|
# Represents the parsed state of a terminal screen.
|
|
5
5
|
# Provides high-level query methods for AI consumption.
|
|
6
6
|
class State
|
|
7
|
-
attr_reader :rows, :cols, :grid, :cursor
|
|
7
|
+
attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format
|
|
8
8
|
|
|
9
9
|
def initialize(data)
|
|
10
10
|
raise ArgumentError, "State data must include :size key" unless data[:size]
|
|
@@ -14,6 +14,13 @@ module TUITD
|
|
|
14
14
|
@cols = data[:size][:cols]
|
|
15
15
|
@grid = data[:rows]
|
|
16
16
|
@cursor = data[:cursor]
|
|
17
|
+
|
|
18
|
+
cursor_info = data[:cursor].is_a?(Hash) ? data[:cursor] : {}
|
|
19
|
+
@cursor_visible = data.key?(:cursor_visible) ? data[:cursor_visible] : (cursor_info[:visible] != false)
|
|
20
|
+
@cursor_style = data.key?(:cursor_style) ? data[:cursor_style] : (cursor_info[:style] || 1)
|
|
21
|
+
|
|
22
|
+
@mouse_mode = data[:mouse_mode] || :none
|
|
23
|
+
@mouse_format = data[:mouse_format] || :normal
|
|
17
24
|
end
|
|
18
25
|
|
|
19
26
|
# Get plain text of the entire terminal (no ANSI)
|
data/lib/tui_td/version.rb
CHANGED