tui-td 0.2.5 → 0.2.7

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: b5a923ea75814849ee5cf0633c81c09e2db156e47df52f6f92f2b314f743f3b7
4
- data.tar.gz: c5cb55618e38bcc06a477bedce42d61d8f0c497ff38f887462943e8d094863b0
3
+ metadata.gz: 2f09dac0e163cc0b1f2e699d66fe25fd656003b28ad17867a32154073cec8a62
4
+ data.tar.gz: a46f622e3a7a89c0451e603e89266575eec49b346af851c8bce393230684c759
5
5
  SHA512:
6
- metadata.gz: 1d36aac819998194c942dc9fc16c96ef0b4ef9fa7d549649f4bdd78cd59687858939ef7e1303d215a13e1dcd0957350d3839e43af94d4b837eb509ea5541cb58
7
- data.tar.gz: 3d9f2fe282a9e32a274cea860ef5592e7666ce48121d590395c926bd0119d0f970ad2d2f232b88b0323742df8ef42b18686cd60ca75007d9f739cc2e8f4bae5b
6
+ metadata.gz: adb1c08cd89f21f59e270c73d94f8542ada7949069ebb5c6dcad27dc860264df93f09dea8e8c2053e90f909ca9c5405254e0f51d2afbd6dba194e1b3d293e4d3
7
+ data.tar.gz: 70597354fa770012221aa26f03a2ce4198f13ebb8ea66f1bacba20f7be740b1487d31c6908de19036889ae3ab0f60fc4a7cf57d993c5b938453f51618ecb9b28
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.2.7
4
+
5
+ - Screenshot rendering for 23 special characters: blocks (▀ ▄ █), triangles (▲ ▼), arrows (↑ ↓ → ←), half blocks (▌ ▐)
6
+ - Screenshot rendering for symbols: checkmarks (✓ ✗ ✖), checkboxes (☐ ☑ ☒), gear (⚙), warning (⚠), info (ℹ)
7
+ - Screenshot rendering for punctuation: ellipsis (…), em dash (—)
8
+ - Cursor drawing support in screenshot renderer
9
+ - Braille character rendering in screenshot
10
+ - Rounded corner box-drawing characters (╭ ╮ ╯ ╰) in screenshot
11
+ - `page_up` / `page_down` key support in test runner and driver
12
+ - Fixed junction pixels in rounded corner box-drawing characters
13
+
14
+ ## 0.2.6
15
+
16
+ - ISO-2022 charset switching support (G0/G1 designators, Shift Out/In) with DEC Special Character & Line Drawing mapping
17
+ - SGR mouse reporting mode parsing (1000, 1002, 1003, 1006)
18
+ - Mouse reporting and cursor visibility/style reconstruction in build_frame
19
+ - Fixed `state_data` in `driver.rb` to unconditionally refresh terminal state instead of returning stale cache
20
+ - New `cursor_visible`, `cursor_style`, `mouse_mode`, `mouse_format` attributes on `State`
21
+ - HTML and screenshot styling for new cursor/mouse attributes
22
+
3
23
  ## 0.2.5
4
24
 
5
25
  - MCP smoke test expanded: 20 → 54 assertions, covers all 10 tools plus error paths (88% server coverage)
data/README.md CHANGED
@@ -417,10 +417,14 @@ Top-level structure returned by `state_data` / `--json`:
417
417
 
418
418
  ```json
419
419
  {
420
- "size": {"rows": 40, "cols": 120},
421
- "cursor": {"row": 5, "col": 12},
422
- "rows": [[{"char": "A", "fg": "cyan", ...}]],
423
- "raw": "\e[31mred\e[0m\n..."
420
+ "size": {"rows": 40, "cols": 120},
421
+ "cursor": {"row": 5, "col": 12},
422
+ "cursor_visible": true,
423
+ "cursor_style": "block",
424
+ "mouse_mode": null,
425
+ "mouse_format": null,
426
+ "rows": [[{"char": "A", "fg": "cyan", ...}]],
427
+ "raw": "\e[31mred\e[0m\n..."
424
428
  }
425
429
  ```
426
430
 
@@ -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@`fhlmnRrsu]/)
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
- saved_cursor = new_saved if new_saved
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
- saved_cursor = { row: cursor[:row], col: cursor[:col] }
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: \e( B \e) 0 etc. (3 chars total)
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
- 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
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: 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@`fhlmnRrsu])$/)
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
- params = match[1].split(";").map(&:to_i)
237
- command = match[2]
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", "l" # DEC private mode set/reset — skip
313
- nil
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
@@ -77,6 +77,8 @@ module TUITD
77
77
  when :backspace then send("\u007f")
78
78
  when :ctrl_c then send("\u0003")
79
79
  when :ctrl_d then send("\u0004")
80
+ when :page_up then send("\e[5~")
81
+ when :page_down then send("\e[6~")
80
82
  else send(keys.to_s)
81
83
  end
82
84
  end
@@ -142,7 +144,7 @@ module TUITD
142
144
 
143
145
  # Get structured terminal state as a Hash
144
146
  def state_data
145
- refresh_state! if @state.nil?
147
+ refresh_state!
146
148
  @state
147
149
  end
148
150
 
@@ -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: is_cursor?(ri, ci)
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
- classes << "cursor-cell" if run[:has_cursor]
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)
@@ -107,6 +107,100 @@ 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_rounded],
121
+ "╮" => [false, true, true, false, :light_rounded],
122
+ "╯" => [true, false, true, false, :light_rounded],
123
+ "╰" => [true, false, false, true, :light_rounded],
124
+ "┌" => [false, true, false, true, :light],
125
+ "┍" => [false, true, false, true, :light],
126
+ "┎" => [false, true, false, true, :light],
127
+ "┏" => [false, true, false, true, :heavy],
128
+ "┐" => [false, true, true, false, :light],
129
+ "┑" => [false, true, true, false, :light],
130
+ "┒" => [false, true, true, false, :light],
131
+ "┓" => [false, true, true, false, :heavy],
132
+ "└" => [true, false, false, true, :light],
133
+ "┖" => [true, false, false, true, :light],
134
+ "┗" => [true, false, false, true, :heavy],
135
+ "┘" => [true, false, true, false, :light],
136
+ "┙" => [true, false, true, false, :light],
137
+ "┚" => [true, false, true, false, :light],
138
+ "┛" => [true, false, true, false, :heavy],
139
+ # double corners
140
+ "╔" => [false, true, false, true, :double],
141
+ "╗" => [false, true, true, false, :double],
142
+ "╚" => [true, false, false, true, :double],
143
+ "╝" => [true, false, true, false, :double],
144
+ # T-junctions
145
+ "├" => [true, true, false, true, :light],
146
+ "┣" => [true, true, false, true, :heavy],
147
+ "┤" => [true, true, true, false, :light],
148
+ "┫" => [true, true, true, false, :heavy],
149
+ "┬" => [false, true, true, true, :light],
150
+ "┳" => [false, true, true, true, :heavy],
151
+ "┴" => [true, false, true, true, :light],
152
+ "┻" => [true, false, true, true, :heavy],
153
+ # double T-junctions
154
+ "╠" => [true, true, false, true, :double],
155
+ "╣" => [true, true, true, false, :double],
156
+ "╦" => [false, true, true, true, :double],
157
+ "╩" => [true, false, true, true, :double],
158
+ # crosses
159
+ "┼" => [true, true, true, true, :light],
160
+ "╋" => [true, true, true, true, :heavy],
161
+ "╬" => [true, true, true, true, :double],
162
+ # single lines (ends)
163
+ "╴" => [false, false, true, false, :light],
164
+ "╵" => [true, false, false, false, :light],
165
+ "╶" => [false, false, false, true, :light],
166
+ "╷" => [false, true, false, false, :light],
167
+ "╸" => [false, false, true, false, :heavy],
168
+ "╹" => [true, false, false, false, :heavy],
169
+ "╺" => [false, false, false, true, :heavy],
170
+ "╻" => [false, true, false, false, :heavy],
171
+ # mixed corners/junctions
172
+ "┿" => [true, true, true, true, :light],
173
+ "╀" => [true, true, true, true, :light],
174
+ "╁" => [true, true, true, true, :light],
175
+ "╂" => [true, true, true, true, :light],
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
+ "╈" => [true, true, true, true, :heavy],
182
+ "╉" => [true, true, true, true, :heavy],
183
+ "╊" => [true, true, true, true, :heavy],
184
+ "╒" => [false, true, false, true, :double],
185
+ "╓" => [false, true, false, true, :double],
186
+ "╕" => [false, true, true, false, :double],
187
+ "╖" => [false, true, true, false, :double],
188
+ "╘" => [true, false, false, true, :double],
189
+ "╙" => [true, false, false, true, :double],
190
+ "╛" => [true, false, true, false, :double],
191
+ "╜" => [true, false, true, false, :double],
192
+ "╞" => [true, true, false, true, :double],
193
+ "╟" => [true, true, false, true, :double],
194
+ "╡" => [true, true, true, false, :double],
195
+ "╢" => [true, true, true, false, :double],
196
+ "╤" => [false, true, true, true, :double],
197
+ "╥" => [false, true, true, true, :double],
198
+ "╧" => [true, false, true, true, :double],
199
+ "╨" => [true, false, true, true, :double],
200
+ "╪" => [true, true, true, true, :double],
201
+ "╫" => [true, true, true, true, :double]
202
+ }.freeze
203
+
110
204
  private_constant :FONT
111
205
 
112
206
  def initialize(state)
@@ -129,6 +223,8 @@ module TUITD
129
223
  end
130
224
  end
131
225
 
226
+ draw_cursor(image)
227
+
132
228
  image.save(output_path)
133
229
  output_path
134
230
  end
@@ -151,7 +247,116 @@ module TUITD
151
247
 
152
248
  fill_rect(image, px, py, CELL_W, CELL_H, bg_rgb)
153
249
 
154
- return if char == " " || char.ord < 32 || char.ord > 126
250
+ if box_drawing?(char)
251
+ draw_box_character(image, px, py, char, fg_rgb)
252
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
253
+ return
254
+ end
255
+
256
+ char_ord = char.ord
257
+ if char_ord == 10095 # '❯'
258
+ draw_chevron(image, px, py, fg_rgb)
259
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
260
+ return
261
+ elsif char_ord == 9210 || char_ord == 9679 # '⏺' or '●'
262
+ draw_circle(image, px, py, fg_rgb)
263
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
264
+ return
265
+ elsif char_ord >= 0x2800 && char_ord <= 0x28ff # Braille spinner
266
+ draw_braille(image, px, py, char, fg_rgb)
267
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
268
+ return
269
+ elsif char_ord == 0x2580 # '▀'
270
+ fill_rect(image, px, py, CELL_W, 8, fg_rgb)
271
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
272
+ return
273
+ elsif char_ord == 0x2584 # '▄'
274
+ fill_rect(image, px, py + 8, CELL_W, 8, fg_rgb)
275
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
276
+ return
277
+ elsif char_ord == 0x2588 # '█'
278
+ fill_rect(image, px, py, CELL_W, CELL_H, fg_rgb)
279
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
280
+ return
281
+ elsif char_ord == 0x25B2 # '▲'
282
+ draw_up_triangle(image, px, py, fg_rgb)
283
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
284
+ return
285
+ elsif char_ord == 0x25BC # '▼'
286
+ draw_down_triangle(image, px, py, fg_rgb)
287
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
288
+ return
289
+ elsif char_ord == 0x2713 # '✓'
290
+ draw_checkmark(image, px, py, fg_rgb)
291
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
292
+ return
293
+ elsif char_ord == 0x2717 # '✗'
294
+ draw_ballot_x(image, px, py, fg_rgb)
295
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
296
+ return
297
+ elsif char_ord == 0x2191 # '↑'
298
+ draw_up_arrow(image, px, py, fg_rgb)
299
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
300
+ return
301
+ elsif char_ord == 0x2192 # '→'
302
+ draw_right_arrow(image, px, py, fg_rgb)
303
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
304
+ return
305
+ elsif char_ord == 0x2193 # '↓'
306
+ draw_down_arrow(image, px, py, fg_rgb)
307
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
308
+ return
309
+ elsif char_ord == 0x2699 # '⚙'
310
+ draw_gear(image, px, py, fg_rgb)
311
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
312
+ return
313
+ elsif char_ord == 0x26A0 # '⚠'
314
+ draw_warning(image, px, py, fg_rgb)
315
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
316
+ return
317
+ elsif char_ord == 0x2026 # '…'
318
+ draw_ellipsis(image, px, py, fg_rgb)
319
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
320
+ return
321
+ elsif char_ord == 0x2014 # '—'
322
+ (px..(px + 7)).each { |x| image[x, py + 8] = ChunkyPNG::Color.rgb(*fg_rgb) }
323
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
324
+ return
325
+ elsif char_ord == 0x2190 # '←'
326
+ draw_left_arrow(image, px, py, fg_rgb)
327
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
328
+ return
329
+ elsif char_ord == 0x258C # '▌'
330
+ draw_left_half_block(image, px, py, fg_rgb)
331
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
332
+ return
333
+ elsif char_ord == 0x2590 # '▐'
334
+ draw_right_half_block(image, px, py, fg_rgb)
335
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
336
+ return
337
+ elsif char_ord == 0x2610 # '☐'
338
+ draw_empty_checkbox(image, px, py, fg_rgb)
339
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
340
+ return
341
+ elsif char_ord == 0x2611 # '☑'
342
+ draw_checked_checkbox(image, px, py, fg_rgb)
343
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
344
+ return
345
+ elsif char_ord == 0x2612 # '☒'
346
+ draw_x_checkbox(image, px, py, fg_rgb)
347
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
348
+ return
349
+ elsif char_ord == 0x2139 # 'ℹ'
350
+ draw_info_symbol(image, px, py, fg_rgb)
351
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
352
+ return
353
+ elsif char_ord == 0x2716 # '✖'
354
+ draw_heavy_x(image, px, py, fg_rgb)
355
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
356
+ return
357
+ end
358
+
359
+ return if char == " " || char_ord < 32 || char_ord > 126
155
360
 
156
361
  rows_data = glyph_rows(char)
157
362
  return unless rows_data
@@ -200,5 +405,453 @@ module TUITD
200
405
  w.times { |dx| image[px + dx, y] = color }
201
406
  end
202
407
 
408
+ def box_drawing?(char)
409
+ char_ord = char.ord
410
+ char_ord >= 0x2500 && char_ord <= 0x257F
411
+ end
412
+
413
+ def draw_box_character(image, px, py, char, fg_rgb)
414
+ config = BOX_CHARS[char]
415
+
416
+ unless config
417
+ char_ord = char.ord
418
+ if [0x2500, 0x2501, 0x2504, 0x2505, 0x2508, 0x2509, 0x254c, 0x254d, 0x2550].include?(char_ord)
419
+ style = [0x2501, 0x2505, 0x2509, 0x254d].include?(char_ord) ? :heavy : (char_ord == 0x2550 ? :double : :light)
420
+ config = [false, false, true, true, style]
421
+ elsif [0x2502, 0x2503, 0x2506, 0x2507, 0x250a, 0x250b, 0x254e, 0x254f, 0x2551].include?(char_ord)
422
+ style = [0x2503, 0x2507, 0x250b, 0x254f].include?(char_ord) ? :heavy : (char_ord == 0x2551 ? :double : :light)
423
+ config = [true, true, false, false, style]
424
+ else
425
+ config = [true, true, true, true, :light]
426
+ end
427
+ end
428
+
429
+ up, down, left, right, style = config
430
+ cx = px + 4
431
+ cy = py + 8
432
+
433
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
434
+
435
+ if style == :double
436
+ if left
437
+ (px..(cx + 2)).each { |x| image[x, py + 6] = color }
438
+ (px..(cx + 2)).each { |x| image[x, py + 10] = color }
439
+ end
440
+ if right
441
+ ((cx - 2)..(px + 7)).each { |x| image[x, py + 6] = color }
442
+ ((cx - 2)..(px + 10)).each { |x| image[x, py + 10] = color }
443
+ end
444
+ if up
445
+ (py..(cy + 2)).each { |y| image[px + 2, y] = color }
446
+ (py..(cy + 2)).each { |y| image[px + 6, y] = color }
447
+ end
448
+ if down
449
+ ((cy - 2)..(py + 15)).each { |y| image[px + 2, y] = color }
450
+ ((cy - 2)..(py + 15)).each { |y| image[px + 6, y] = color }
451
+ end
452
+ elsif style == :heavy
453
+ if left
454
+ (px..cx).each do |x|
455
+ image[x, cy - 1] = color
456
+ image[x, cy] = color
457
+ image[x, cy + 1] = color
458
+ end
459
+ end
460
+ if right
461
+ (cx..(px + 7)).each do |x|
462
+ image[x, cy - 1] = color
463
+ image[x, cy] = color
464
+ image[x, cy + 1] = color
465
+ end
466
+ end
467
+ if up
468
+ (py..cy).each do |y|
469
+ image[cx - 1, y] = color
470
+ image[cx, y] = color
471
+ image[cx + 1, y] = color
472
+ end
473
+ end
474
+ if down
475
+ (cy..(py + 15)).each do |y|
476
+ image[cx - 1, y] = color
477
+ image[cx, y] = color
478
+ image[cx + 1, y] = color
479
+ end
480
+ end
481
+ elsif style == :light_rounded
482
+ case char
483
+ when "╭"
484
+ (px + 5..px + 7).each { |x| image[x, py + 8] = color }
485
+ (py + 10..py + 15).each { |y| image[px + 4, y] = color }
486
+ image[px + 4, py + 9] = color
487
+ image[px + 5, py + 9] = color
488
+ when "╮"
489
+ (px..px + 3).each { |x| image[x, py + 8] = color }
490
+ (py + 10..py + 15).each { |y| image[px + 4, y] = color }
491
+ image[px + 4, py + 9] = color
492
+ image[px + 3, py + 9] = color
493
+ when "╯"
494
+ (px..px + 3).each { |x| image[x, py + 8] = color }
495
+ (py..py + 6).each { |y| image[px + 4, y] = color }
496
+ image[px + 4, py + 7] = color
497
+ image[px + 3, py + 7] = color
498
+ when "╰"
499
+ (px + 5..px + 7).each { |x| image[x, py + 8] = color }
500
+ (py..py + 6).each { |y| image[px + 4, y] = color }
501
+ image[px + 4, py + 7] = color
502
+ image[px + 5, py + 7] = color
503
+ end
504
+ else # :light
505
+ if left
506
+ (px..cx).each { |x| image[x, cy] = color }
507
+ end
508
+ if right
509
+ (cx..(px + 7)).each { |x| image[x, cy] = color }
510
+ end
511
+ if up
512
+ (py..cy).each { |y| image[cx, y] = color }
513
+ end
514
+ if down
515
+ (cy..(py + 15)).each { |y| image[cx, y] = color }
516
+ end
517
+ end
518
+ end
519
+
520
+ def draw_cursor(image)
521
+ cursor_info = @state[:cursor] || @state["cursor"] || {}
522
+ cursor_vis = @state[:cursor_visible]
523
+ cursor_vis = cursor_info[:visible] if cursor_vis.nil?
524
+ cursor_vis = cursor_info["visible"] if cursor_vis.nil?
525
+ cursor_vis = false if cursor_vis.nil? # default invisible
526
+
527
+ return unless cursor_vis
528
+
529
+ ri = cursor_info[:row] || cursor_info["row"] || 0
530
+ ci = cursor_info[:col] || cursor_info["col"] || 0
531
+
532
+ return if ri < 0 || ri >= @rows || ci < 0 || ci >= @cols
533
+
534
+ style_val = @state[:cursor_style] || cursor_info[:style] || cursor_info["style"] || 1
535
+
536
+ px = ci * CELL_W
537
+ py = ri * CELL_H
538
+
539
+ color_rgb = [255, 255, 255] # Weiß standardmäßig
540
+ color = ChunkyPNG::Color.rgb(*color_rgb)
541
+
542
+ case style_val
543
+ when 1, 2 # Blinking Block oder Steady Block
544
+ CELL_H.times do |dy|
545
+ CELL_W.times do |dx|
546
+ x = px + dx
547
+ y = py + dy
548
+ next if x >= image.width || y >= image.height
549
+ original_color = image[x, y]
550
+ r = 255 - ChunkyPNG::Color.r(original_color)
551
+ g = 255 - ChunkyPNG::Color.g(original_color)
552
+ b = 255 - ChunkyPNG::Color.b(original_color)
553
+ image[x, y] = ChunkyPNG::Color.rgb(r, g, b)
554
+ end
555
+ end
556
+ when 3, 4 # Underline
557
+ 2.times do |h_offset|
558
+ y = py + CELL_H - 1 - h_offset
559
+ next if y >= image.height
560
+ CELL_W.times do |dx|
561
+ x = px + dx
562
+ next if x >= image.width
563
+ image[x, y] = color
564
+ end
565
+ end
566
+ when 5, 6 # Bar
567
+ 2.times do |w_offset|
568
+ x = px + w_offset
569
+ next if x >= image.width
570
+ CELL_H.times do |dy|
571
+ y = py + dy
572
+ next if y >= image.height
573
+ image[x, y] = color
574
+ end
575
+ end
576
+ end
577
+ end
578
+
579
+ def draw_chevron(image, px, py, fg_rgb)
580
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
581
+ (0..3).each do |i|
582
+ image[px + 2 + i, py + 4 + i] = color
583
+ image[px + 3 + i, py + 4 + i] = color # bold/thick chevron
584
+
585
+ image[px + 5 - i, py + 8 + i] = color
586
+ image[px + 6 - i, py + 8 + i] = color # bold/thick chevron
587
+ end
588
+ end
589
+
590
+ def draw_circle(image, px, py, fg_rgb)
591
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
592
+ cx = px + 4
593
+ cy = py + 8
594
+ (-3..3).each do |dy|
595
+ r_width = case dy.abs
596
+ when 3 then 1
597
+ when 2 then 2
598
+ else 3
599
+ end
600
+ (-r_width..r_width).each do |dx|
601
+ x = cx + dx
602
+ y = cy + dy
603
+ next if x < px || x >= px + CELL_W || y < py || y >= py + CELL_H
604
+ image[x, y] = color
605
+ end
606
+ end
607
+ end
608
+
609
+ def draw_braille(image, px, py, char, fg_rgb)
610
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
611
+ mask = char.ord - 0x2800
612
+ dot_coords = [
613
+ [2, 3], # Dot 1
614
+ [2, 6], # Dot 2
615
+ [2, 9], # Dot 3
616
+ [5, 3], # Dot 4
617
+ [5, 6], # Dot 5
618
+ [5, 9], # Dot 6
619
+ [2, 12], # Dot 7
620
+ [5, 12] # Dot 8
621
+ ]
622
+ dot_coords.each_with_index do |(dx, dy), idx|
623
+ if (mask & (1 << idx)) != 0
624
+ 2.times do |ddy|
625
+ 2.times do |ddx|
626
+ x = px + dx + ddx
627
+ y = py + dy + ddy
628
+ next if x >= image.width || y >= image.height
629
+ image[x, y] = color
630
+ end
631
+ end
632
+ end
633
+ end
634
+ end
635
+
636
+ def draw_up_triangle(image, px, py, fg_rgb)
637
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
638
+ (5..8).each do |dy|
639
+ width = dy - 5
640
+ (4 - width..4 + width).each do |dx|
641
+ image[px + dx, py + dy] = color
642
+ end
643
+ end
644
+ end
645
+
646
+ def draw_down_triangle(image, px, py, fg_rgb)
647
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
648
+ (5..8).each do |dy|
649
+ width = 8 - dy
650
+ (4 - width..4 + width).each do |dx|
651
+ image[px + dx, py + dy] = color
652
+ end
653
+ end
654
+ end
655
+
656
+ def draw_checkmark(image, px, py, fg_rgb)
657
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
658
+ image[px + 2, py + 8] = color
659
+ image[px + 3, py + 9] = color
660
+ image[px + 4, py + 10] = color
661
+ image[px + 5, py + 8] = color
662
+ image[px + 6, py + 6] = color
663
+ image[px + 7, py + 4] = color
664
+ end
665
+
666
+ def draw_ballot_x(image, px, py, fg_rgb)
667
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
668
+ image[px + 2, py + 5] = color
669
+ image[px + 3, py + 6] = color
670
+ image[px + 3, py + 7] = color
671
+ image[px + 4, py + 8] = color
672
+ image[px + 5, py + 9] = color
673
+ image[px + 5, py + 10] = color
674
+ image[px + 6, py + 11] = color
675
+ image[px + 2, py + 11] = color
676
+ image[px + 3, py + 10] = color
677
+ image[px + 3, py + 9] = color
678
+ image[px + 5, py + 7] = color
679
+ image[px + 5, py + 6] = color
680
+ image[px + 6, py + 5] = color
681
+ end
682
+
683
+ def draw_up_arrow(image, px, py, fg_rgb)
684
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
685
+ (3..12).each { |dy| image[px + 4, py + dy] = color }
686
+ image[px + 3, py + 4] = color
687
+ image[px + 5, py + 4] = color
688
+ image[px + 2, py + 5] = color
689
+ image[px + 6, py + 5] = color
690
+ end
691
+
692
+ def draw_down_arrow(image, px, py, fg_rgb)
693
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
694
+ (3..12).each { |dy| image[px + 4, py + dy] = color }
695
+ image[px + 3, py + 11] = color
696
+ image[px + 5, py + 11] = color
697
+ image[px + 2, py + 10] = color
698
+ image[px + 6, py + 10] = color
699
+ end
700
+
701
+ def draw_right_arrow(image, px, py, fg_rgb)
702
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
703
+ (1..6).each { |dx| image[px + dx, py + 8] = color }
704
+ image[px + 5, py + 7] = color
705
+ image[px + 5, py + 9] = color
706
+ image[px + 4, py + 6] = color
707
+ image[px + 4, py + 10] = color
708
+ end
709
+
710
+ def draw_gear(image, px, py, fg_rgb)
711
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
712
+ image[px + 4, py + 6] = color
713
+ image[px + 4, py + 10] = color
714
+ image[px + 2, py + 8] = color
715
+ image[px + 6, py + 8] = color
716
+ image[px + 3, py + 7] = color
717
+ image[px + 5, py + 7] = color
718
+ image[px + 3, py + 9] = color
719
+ image[px + 5, py + 9] = color
720
+ image[px + 4, py + 5] = color
721
+ image[px + 4, py + 11] = color
722
+ image[px + 1, py + 8] = color
723
+ image[px + 7, py + 8] = color
724
+ image[px + 2, py + 6] = color
725
+ image[px + 6, py + 6] = color
726
+ image[px + 2, py + 10] = color
727
+ image[px + 6, py + 10] = color
728
+ end
729
+
730
+ def draw_warning(image, px, py, fg_rgb)
731
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
732
+ image[px + 4, py + 3] = color
733
+ image[px + 3, py + 4] = color
734
+ image[px + 5, py + 4] = color
735
+ image[px + 3, py + 5] = color
736
+ image[px + 5, py + 5] = color
737
+ image[px + 2, py + 6] = color
738
+ image[px + 6, py + 6] = color
739
+ image[px + 2, py + 7] = color
740
+ image[px + 6, py + 7] = color
741
+ image[px + 1, py + 8] = color
742
+ image[px + 7, py + 8] = color
743
+ image[px + 1, py + 9] = color
744
+ image[px + 7, py + 9] = color
745
+ (1..7).each { |dx| image[px + dx, py + 10] = color }
746
+ image[px + 4, py + 5] = color
747
+ image[px + 4, py + 6] = color
748
+ image[px + 4, py + 7] = color
749
+ image[px + 4, py + 9] = color
750
+ end
751
+
752
+ def draw_ellipsis(image, px, py, fg_rgb)
753
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
754
+ image[px + 1, py + 12] = color
755
+ image[px + 4, py + 12] = color
756
+ image[px + 6, py + 12] = color
757
+ end
758
+
759
+ def draw_left_arrow(image, px, py, fg_rgb)
760
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
761
+ (1..6).each { |dx| image[px + dx, py + 8] = color }
762
+ image[px + 2, py + 7] = color
763
+ image[px + 2, py + 9] = color
764
+ image[px + 3, py + 6] = color
765
+ image[px + 3, py + 10] = color
766
+ end
767
+
768
+ def draw_left_half_block(image, px, py, fg_rgb)
769
+ fill_rect(image, px, py, 4, CELL_H, fg_rgb)
770
+ end
771
+
772
+ def draw_right_half_block(image, px, py, fg_rgb)
773
+ fill_rect(image, px + 4, py, 4, CELL_H, fg_rgb)
774
+ end
775
+
776
+ def draw_checkbox_border(image, px, py, color)
777
+ (1..6).each do |dx|
778
+ image[px + dx, py + 4] = color
779
+ image[px + dx, py + 11] = color
780
+ end
781
+ (4..11).each do |dy|
782
+ image[px + 1, py + dy] = color
783
+ image[px + 6, py + dy] = color
784
+ end
785
+ end
786
+
787
+ def draw_empty_checkbox(image, px, py, fg_rgb)
788
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
789
+ draw_checkbox_border(image, px, py, color)
790
+ end
791
+
792
+ def draw_checked_checkbox(image, px, py, fg_rgb)
793
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
794
+ draw_checkbox_border(image, px, py, color)
795
+ image[px + 2, py + 8] = color
796
+ image[px + 3, py + 9] = color
797
+ image[px + 4, py + 7] = color
798
+ image[px + 5, py + 5] = color
799
+ end
800
+
801
+ def draw_x_checkbox(image, px, py, fg_rgb)
802
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
803
+ draw_checkbox_border(image, px, py, color)
804
+ image[px + 2, py + 5] = color
805
+ image[px + 2, py + 6] = color
806
+ image[px + 3, py + 7] = color
807
+ image[px + 3, py + 8] = color
808
+ image[px + 4, py + 7] = color
809
+ image[px + 4, py + 8] = color
810
+ image[px + 5, py + 9] = color
811
+ image[px + 5, py + 10] = color
812
+ image[px + 2, py + 10] = color
813
+ image[px + 2, py + 9] = color
814
+ image[px + 5, py + 6] = color
815
+ image[px + 5, py + 5] = color
816
+ end
817
+
818
+ def draw_info_symbol(image, px, py, fg_rgb)
819
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
820
+ (3..5).each do |dx|
821
+ image[px + dx, py + 4] = color
822
+ image[px + dx, py + 12] = color
823
+ end
824
+ image[px + 2, py + 5] = color
825
+ image[px + 6, py + 5] = color
826
+ image[px + 2, py + 11] = color
827
+ image[px + 6, py + 11] = color
828
+ (6..10).each do |dy|
829
+ image[px + 1, py + dy] = color
830
+ image[px + 7, py + dy] = color
831
+ end
832
+ image[px + 4, py + 6] = color
833
+ (8..10).each do |dy|
834
+ image[px + 4, py + dy] = color
835
+ end
836
+ end
837
+
838
+ def draw_heavy_x(image, px, py, fg_rgb)
839
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
840
+ [4, 5, 11, 12].each do |dy|
841
+ image[px + 1, py + dy] = color
842
+ image[px + 2, py + dy] = color
843
+ image[px + 5, py + dy] = color
844
+ image[px + 6, py + dy] = color
845
+ end
846
+ [6, 7, 9, 10].each do |dy|
847
+ image[px + 2, py + dy] = color
848
+ image[px + 3, py + dy] = color
849
+ image[px + 4, py + dy] = color
850
+ image[px + 5, py + dy] = color
851
+ end
852
+ image[px + 3, py + 8] = color
853
+ image[px + 4, py + 8] = color
854
+ end
855
+
203
856
  end
204
857
  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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUITD
4
- VERSION = "0.2.5"
4
+ VERSION = "0.2.7"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tui-td
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus