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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5a923ea75814849ee5cf0633c81c09e2db156e47df52f6f92f2b314f743f3b7
4
- data.tar.gz: c5cb55618e38bcc06a477bedce42d61d8f0c497ff38f887462943e8d094863b0
3
+ metadata.gz: 2716bc85090a6430068ad8820f9736657a3f122571999e34c39529060f59d487
4
+ data.tar.gz: b73ff229c0396a87a5121b43ff81edf65be1f13de1aed58a7404cd263a0ba84e
5
5
  SHA512:
6
- metadata.gz: 1d36aac819998194c942dc9fc16c96ef0b4ef9fa7d549649f4bdd78cd59687858939ef7e1303d215a13e1dcd0957350d3839e43af94d4b837eb509ea5541cb58
7
- data.tar.gz: 3d9f2fe282a9e32a274cea860ef5592e7666ce48121d590395c926bd0119d0f970ad2d2f232b88b0323742df8ef42b18686cd60ca75007d9f739cc2e8f4bae5b
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)
@@ -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
@@ -142,7 +142,7 @@ module TUITD
142
142
 
143
143
  # Get structured terminal state as a Hash
144
144
  def state_data
145
- refresh_state! if @state.nil?
145
+ refresh_state!
146
146
  @state
147
147
  end
148
148
 
@@ -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,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)
@@ -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.6"
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.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus