asciinema_win 0.1.0

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.
@@ -0,0 +1,859 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Windows Console API bindings using Ruby's built-in Fiddle
4
+ # This module provides low-level access to Windows Console functions
5
+ # for terminal manipulation, ANSI support detection, and cursor control.
6
+ #
7
+ # Only loaded and functional on Windows platforms.
8
+
9
+ require "fiddle"
10
+ require "fiddle/import"
11
+
12
+ module Rich
13
+ module Win32Console
14
+ extend Fiddle::Importer
15
+
16
+ # Standard handle constants
17
+ STD_INPUT_HANDLE = -10
18
+ STD_OUTPUT_HANDLE = -11
19
+ STD_ERROR_HANDLE = -12
20
+
21
+ # Console mode flags
22
+ ENABLE_PROCESSED_INPUT = 0x0001
23
+ ENABLE_LINE_INPUT = 0x0002
24
+ ENABLE_ECHO_INPUT = 0x0004
25
+ ENABLE_WINDOW_INPUT = 0x0008
26
+ ENABLE_MOUSE_INPUT = 0x0010
27
+ ENABLE_INSERT_MODE = 0x0020
28
+ ENABLE_QUICK_EDIT_MODE = 0x0040
29
+ ENABLE_EXTENDED_FLAGS = 0x0080
30
+ ENABLE_AUTO_POSITION = 0x0100
31
+ ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
32
+
33
+ # Output mode flags
34
+ ENABLE_PROCESSED_OUTPUT = 0x0001
35
+ ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002
36
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
37
+ DISABLE_NEWLINE_AUTO_RETURN = 0x0008
38
+ ENABLE_LVB_GRID_WORLDWIDE = 0x0010
39
+
40
+ # Console text attributes (foreground colors)
41
+ FOREGROUND_BLUE = 0x0001
42
+ FOREGROUND_GREEN = 0x0002
43
+ FOREGROUND_RED = 0x0004
44
+ FOREGROUND_INTENSITY = 0x0008
45
+
46
+ # Console text attributes (background colors)
47
+ BACKGROUND_BLUE = 0x0010
48
+ BACKGROUND_GREEN = 0x0020
49
+ BACKGROUND_RED = 0x0040
50
+ BACKGROUND_INTENSITY = 0x0080
51
+
52
+ # Additional text attributes
53
+ COMMON_LVB_LEADING_BYTE = 0x0100
54
+ COMMON_LVB_TRAILING_BYTE = 0x0200
55
+ COMMON_LVB_GRID_HORIZONTAL = 0x0400
56
+ COMMON_LVB_GRID_LVERTICAL = 0x0800
57
+ COMMON_LVB_GRID_RVERTICAL = 0x1000
58
+ COMMON_LVB_REVERSE_VIDEO = 0x4000
59
+ COMMON_LVB_UNDERSCORE = 0x8000
60
+
61
+ # ANSI color number to Windows console attribute mapping
62
+ # Maps ANSI color indices (0-15) to Windows FOREGROUND/BACKGROUND values
63
+ ANSI_TO_WINDOWS_FG = [
64
+ 0, # 0: Black
65
+ FOREGROUND_RED, # 1: Red
66
+ FOREGROUND_GREEN, # 2: Green
67
+ FOREGROUND_RED | FOREGROUND_GREEN, # 3: Yellow
68
+ FOREGROUND_BLUE, # 4: Blue
69
+ FOREGROUND_RED | FOREGROUND_BLUE, # 5: Magenta
70
+ FOREGROUND_GREEN | FOREGROUND_BLUE, # 6: Cyan
71
+ FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE, # 7: White
72
+ FOREGROUND_INTENSITY, # 8: Bright Black (Gray)
73
+ FOREGROUND_RED | FOREGROUND_INTENSITY, # 9: Bright Red
74
+ FOREGROUND_GREEN | FOREGROUND_INTENSITY, # 10: Bright Green
75
+ FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_INTENSITY, # 11: Bright Yellow
76
+ FOREGROUND_BLUE | FOREGROUND_INTENSITY, # 12: Bright Blue
77
+ FOREGROUND_RED | FOREGROUND_BLUE | FOREGROUND_INTENSITY, # 13: Bright Magenta
78
+ FOREGROUND_GREEN | FOREGROUND_BLUE | FOREGROUND_INTENSITY, # 14: Bright Cyan
79
+ FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE | FOREGROUND_INTENSITY # 15: Bright White
80
+ ].freeze
81
+
82
+ ANSI_TO_WINDOWS_BG = [
83
+ 0, # 0: Black
84
+ BACKGROUND_RED, # 1: Red
85
+ BACKGROUND_GREEN, # 2: Green
86
+ BACKGROUND_RED | BACKGROUND_GREEN, # 3: Yellow
87
+ BACKGROUND_BLUE, # 4: Blue
88
+ BACKGROUND_RED | BACKGROUND_BLUE, # 5: Magenta
89
+ BACKGROUND_GREEN | BACKGROUND_BLUE, # 6: Cyan
90
+ BACKGROUND_RED | BACKGROUND_GREEN | BACKGROUND_BLUE, # 7: White
91
+ BACKGROUND_INTENSITY, # 8: Bright Black (Gray)
92
+ BACKGROUND_RED | BACKGROUND_INTENSITY, # 9: Bright Red
93
+ BACKGROUND_GREEN | BACKGROUND_INTENSITY, # 10: Bright Green
94
+ BACKGROUND_RED | BACKGROUND_GREEN | BACKGROUND_INTENSITY, # 11: Bright Yellow
95
+ BACKGROUND_BLUE | BACKGROUND_INTENSITY, # 12: Bright Blue
96
+ BACKGROUND_RED | BACKGROUND_BLUE | BACKGROUND_INTENSITY, # 13: Bright Magenta
97
+ BACKGROUND_GREEN | BACKGROUND_BLUE | BACKGROUND_INTENSITY, # 14: Bright Cyan
98
+ BACKGROUND_RED | BACKGROUND_GREEN | BACKGROUND_BLUE | BACKGROUND_INTENSITY # 15: Bright White
99
+ ].freeze
100
+
101
+ if Gem.win_platform?
102
+ dlload "kernel32.dll"
103
+
104
+ # HANDLE WINAPI GetStdHandle(DWORD nStdHandle)
105
+ extern "void* GetStdHandle(unsigned long)"
106
+
107
+ # BOOL WINAPI GetConsoleMode(HANDLE hConsoleHandle, LPDWORD lpMode)
108
+ extern "int GetConsoleMode(void*, unsigned long*)"
109
+
110
+ # BOOL WINAPI SetConsoleMode(HANDLE hConsoleHandle, DWORD dwMode)
111
+ extern "int SetConsoleMode(void*, unsigned long)"
112
+
113
+ # BOOL WINAPI GetConsoleScreenBufferInfo(HANDLE hConsoleOutput, PCONSOLE_SCREEN_BUFFER_INFO lpConsoleScreenBufferInfo)
114
+ extern "int GetConsoleScreenBufferInfo(void*, void*)"
115
+
116
+ # BOOL WINAPI SetConsoleCursorPosition(HANDLE hConsoleOutput, COORD dwCursorPosition)
117
+ extern "int SetConsoleCursorPosition(void*, unsigned long)"
118
+
119
+ # BOOL WINAPI SetConsoleTextAttribute(HANDLE hConsoleOutput, WORD wAttributes)
120
+ extern "int SetConsoleTextAttribute(void*, unsigned short)"
121
+
122
+ # BOOL WINAPI FillConsoleOutputCharacterW(HANDLE hConsoleOutput, WCHAR cCharacter, DWORD nLength, COORD dwWriteCoord, LPDWORD lpNumberOfCharsWritten)
123
+ extern "int FillConsoleOutputCharacterW(void*, unsigned short, unsigned long, unsigned long, unsigned long*)"
124
+
125
+ # BOOL WINAPI FillConsoleOutputAttribute(HANDLE hConsoleOutput, WORD wAttribute, DWORD nLength, COORD dwWriteCoord, LPDWORD lpNumberOfAttrsWritten)
126
+ extern "int FillConsoleOutputAttribute(void*, unsigned short, unsigned long, unsigned long, unsigned long*)"
127
+
128
+ # BOOL WINAPI SetConsoleTitleW(LPCWSTR lpConsoleTitle)
129
+ extern "int SetConsoleTitleW(void*)"
130
+
131
+ # BOOL WINAPI GetConsoleCursorInfo(HANDLE hConsoleOutput, PCONSOLE_CURSOR_INFO lpConsoleCursorInfo)
132
+ extern "int GetConsoleCursorInfo(void*, void*)"
133
+
134
+ # BOOL WINAPI SetConsoleCursorInfo(HANDLE hConsoleOutput, PCONSOLE_CURSOR_INFO lpConsoleCursorInfo)
135
+ extern "int SetConsoleCursorInfo(void*, void*)"
136
+
137
+ # BOOL WINAPI WriteConsoleW(HANDLE hConsoleOutput, CONST VOID* lpBuffer, DWORD nNumberOfCharsToWrite, LPDWORD lpNumberOfCharsWritten, LPVOID lpReserved)
138
+ extern "int WriteConsoleW(void*, void*, unsigned long, unsigned long*, void*)"
139
+
140
+ # BOOL WINAPI FlushConsoleInputBuffer(HANDLE hConsoleInput)
141
+ extern "int FlushConsoleInputBuffer(void*)"
142
+
143
+ # Screen buffer capture APIs for terminal recording
144
+ #
145
+ # BOOL WINAPI ReadConsoleOutputW(
146
+ # HANDLE hConsoleOutput, PCHAR_INFO lpBuffer, COORD dwBufferSize,
147
+ # COORD dwBufferCoord, PSMALL_RECT lpReadRegion)
148
+ extern "int ReadConsoleOutputW(void*, void*, unsigned long, unsigned long, void*)"
149
+
150
+ # BOOL WINAPI ReadConsoleOutputCharacterW(
151
+ # HANDLE hConsoleOutput, LPWSTR lpCharacter, DWORD nLength,
152
+ # COORD dwReadCoord, LPDWORD lpNumberOfCharsRead)
153
+ extern "int ReadConsoleOutputCharacterW(void*, void*, unsigned long, unsigned long, unsigned long*)"
154
+
155
+ # BOOL WINAPI ReadConsoleOutputAttribute(
156
+ # HANDLE hConsoleOutput, LPWORD lpAttribute, DWORD nLength,
157
+ # COORD dwReadCoord, LPDWORD lpNumberOfAttrsRead)
158
+ extern "int ReadConsoleOutputAttribute(void*, void*, unsigned long, unsigned long, unsigned long*)"
159
+ end
160
+
161
+ # CONSOLE_SCREEN_BUFFER_INFO structure layout:
162
+ # typedef struct _CONSOLE_SCREEN_BUFFER_INFO {
163
+ # COORD dwSize; // 4 bytes (2x SHORT)
164
+ # COORD dwCursorPosition; // 4 bytes (2x SHORT)
165
+ # WORD wAttributes; // 2 bytes
166
+ # SMALL_RECT srWindow; // 8 bytes (4x SHORT)
167
+ # COORD dwMaximumWindowSize; // 4 bytes (2x SHORT)
168
+ # } CONSOLE_SCREEN_BUFFER_INFO;
169
+ # Total: 22 bytes
170
+ CONSOLE_SCREEN_BUFFER_INFO_SIZE = 22
171
+
172
+ # CONSOLE_CURSOR_INFO structure layout:
173
+ # typedef struct _CONSOLE_CURSOR_INFO {
174
+ # DWORD dwSize; // 4 bytes
175
+ # BOOL bVisible; // 4 bytes
176
+ # } CONSOLE_CURSOR_INFO;
177
+ # Total: 8 bytes
178
+ CONSOLE_CURSOR_INFO_SIZE = 8
179
+
180
+ class << self
181
+ # @return [Boolean] Whether the current platform is Windows
182
+ def windows?
183
+ Gem.win_platform?
184
+ end
185
+
186
+ # @return [Integer] Handle to stdout
187
+ def stdout_handle
188
+ return nil unless windows?
189
+ @stdout_handle ||= GetStdHandle(STD_OUTPUT_HANDLE)
190
+ end
191
+
192
+ # @return [Integer] Handle to stdin
193
+ def stdin_handle
194
+ return nil unless windows?
195
+ @stdin_handle ||= GetStdHandle(STD_INPUT_HANDLE)
196
+ end
197
+
198
+ # @return [Integer] Handle to stderr
199
+ def stderr_handle
200
+ return nil unless windows?
201
+ @stderr_handle ||= GetStdHandle(STD_ERROR_HANDLE)
202
+ end
203
+
204
+ # Get the current console mode for a handle
205
+ # @param handle [Integer] Console handle (defaults to stdout)
206
+ # @return [Integer, nil] Console mode flags or nil on failure
207
+ def get_console_mode(handle = stdout_handle)
208
+ return nil unless windows? && handle
209
+
210
+ mode_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
211
+ result = GetConsoleMode(handle, mode_ptr)
212
+ return nil if result == 0
213
+
214
+ mode_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
215
+ end
216
+
217
+ # Set the console mode for a handle
218
+ # @param mode [Integer] Console mode flags
219
+ # @param handle [Integer] Console handle (defaults to stdout)
220
+ # @return [Boolean] Success status
221
+ def set_console_mode(mode, handle = stdout_handle)
222
+ return false unless windows? && handle
223
+
224
+ SetConsoleMode(handle, mode) != 0
225
+ end
226
+
227
+ # Check if virtual terminal (ANSI) processing is supported
228
+ # @return [Boolean] True if ANSI escape sequences are supported
229
+ def supports_ansi?
230
+ return @supports_ansi if defined?(@supports_ansi)
231
+
232
+ unless windows?
233
+ @supports_ansi = true # Unix terminals support ANSI
234
+ return @supports_ansi
235
+ end
236
+
237
+ mode = get_console_mode
238
+ return @supports_ansi = false if mode.nil?
239
+
240
+ @supports_ansi = (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0
241
+ end
242
+
243
+ # Enable virtual terminal (ANSI) processing
244
+ # @return [Boolean] True if ANSI mode was successfully enabled
245
+ def enable_ansi!
246
+ return true unless windows? # Already supported on Unix
247
+
248
+ handle = stdout_handle
249
+ return false unless handle
250
+
251
+ current_mode = get_console_mode(handle)
252
+ return false unless current_mode
253
+
254
+ new_mode = current_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING
255
+ result = set_console_mode(new_mode, handle)
256
+
257
+ # Update cached value
258
+ @supports_ansi = result
259
+ result
260
+ end
261
+
262
+ # Disable virtual terminal (ANSI) processing
263
+ # @return [Boolean] True if ANSI mode was successfully disabled
264
+ def disable_ansi!
265
+ return false unless windows?
266
+
267
+ handle = stdout_handle
268
+ return false unless handle
269
+
270
+ current_mode = get_console_mode(handle)
271
+ return false unless current_mode
272
+
273
+ new_mode = current_mode & ~ENABLE_VIRTUAL_TERMINAL_PROCESSING
274
+ result = set_console_mode(new_mode, handle)
275
+
276
+ @supports_ansi = !result if result
277
+ result
278
+ end
279
+
280
+ # Get console screen buffer info
281
+ # @param handle [Integer] Console handle (defaults to stdout)
282
+ # @return [Hash, nil] Screen buffer info or nil on failure
283
+ def get_screen_buffer_info(handle = stdout_handle)
284
+ return nil unless windows? && handle
285
+
286
+ buffer = Fiddle::Pointer.malloc(CONSOLE_SCREEN_BUFFER_INFO_SIZE, Fiddle::RUBY_FREE)
287
+ result = GetConsoleScreenBufferInfo(handle, buffer)
288
+ return nil if result == 0
289
+
290
+ data = buffer[0, CONSOLE_SCREEN_BUFFER_INFO_SIZE]
291
+
292
+ # Unpack the structure
293
+ values = data.unpack("s2 s2 S s4 s2")
294
+
295
+ {
296
+ size: { width: values[0], height: values[1] },
297
+ cursor_position: { x: values[2], y: values[3] },
298
+ attributes: values[4],
299
+ window: {
300
+ left: values[5],
301
+ top: values[6],
302
+ right: values[7],
303
+ bottom: values[8]
304
+ },
305
+ max_window_size: { width: values[9], height: values[10] }
306
+ }
307
+ end
308
+
309
+ # Get the console window dimensions
310
+ # @return [Array<Integer>, nil] [width, height] or nil on failure
311
+ def get_size
312
+ return nil unless windows?
313
+
314
+ info = get_screen_buffer_info
315
+ return nil unless info
316
+
317
+ width = info[:window][:right] - info[:window][:left] + 1
318
+ height = info[:window][:bottom] - info[:window][:top] + 1
319
+
320
+ [width, height]
321
+ end
322
+
323
+ # Get current cursor position
324
+ # @return [Array<Integer>, nil] [x, y] or nil on failure
325
+ def get_cursor_position
326
+ return nil unless windows?
327
+
328
+ info = get_screen_buffer_info
329
+ return nil unless info
330
+
331
+ [info[:cursor_position][:x], info[:cursor_position][:y]]
332
+ end
333
+
334
+ # Set cursor position
335
+ # @param x [Integer] Column (0-indexed)
336
+ # @param y [Integer] Row (0-indexed)
337
+ # @param handle [Integer] Console handle (defaults to stdout)
338
+ # @return [Boolean] Success status
339
+ def set_cursor_position(x, y, handle = stdout_handle)
340
+ return false unless windows? && handle
341
+
342
+ # Pack COORD structure as DWORD (low word = X, high word = Y)
343
+ coord = (y << 16) | (x & 0xFFFF)
344
+ SetConsoleCursorPosition(handle, coord) != 0
345
+ end
346
+
347
+ # Set console text attributes (foreground/background colors)
348
+ # @param attributes [Integer] Attribute flags
349
+ # @param handle [Integer] Console handle (defaults to stdout)
350
+ # @return [Boolean] Success status
351
+ def set_text_attribute(attributes, handle = stdout_handle)
352
+ return false unless windows? && handle
353
+
354
+ SetConsoleTextAttribute(handle, attributes) != 0
355
+ end
356
+
357
+ # Get current text attributes
358
+ # @return [Integer, nil] Current attributes or nil on failure
359
+ def get_text_attributes
360
+ return nil unless windows?
361
+
362
+ info = get_screen_buffer_info
363
+ return nil unless info
364
+
365
+ info[:attributes]
366
+ end
367
+
368
+ # Fill console output with a character
369
+ # @param char [String] Character to fill with
370
+ # @param length [Integer] Number of cells to fill
371
+ # @param x [Integer] Starting column
372
+ # @param y [Integer] Starting row
373
+ # @param handle [Integer] Console handle (defaults to stdout)
374
+ # @return [Integer, nil] Number of characters written or nil on failure
375
+ def fill_output_character(char, length, x, y, handle = stdout_handle)
376
+ return nil unless windows? && handle
377
+
378
+ coord = (y << 16) | (x & 0xFFFF)
379
+ written_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
380
+
381
+ char_code = char.ord
382
+ result = FillConsoleOutputCharacterW(handle, char_code, length, coord, written_ptr)
383
+ return nil if result == 0
384
+
385
+ written_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
386
+ end
387
+
388
+ # Fill console output with an attribute
389
+ # @param attribute [Integer] Attribute to fill with
390
+ # @param length [Integer] Number of cells to fill
391
+ # @param x [Integer] Starting column
392
+ # @param y [Integer] Starting row
393
+ # @param handle [Integer] Console handle (defaults to stdout)
394
+ # @return [Integer, nil] Number of cells written or nil on failure
395
+ def fill_output_attribute(attribute, length, x, y, handle = stdout_handle)
396
+ return nil unless windows? && handle
397
+
398
+ coord = (y << 16) | (x & 0xFFFF)
399
+ written_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
400
+
401
+ result = FillConsoleOutputAttribute(handle, attribute, length, coord, written_ptr)
402
+ return nil if result == 0
403
+
404
+ written_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
405
+ end
406
+
407
+ # Set console window title
408
+ # @param title [String] New window title
409
+ # @return [Boolean] Success status
410
+ def set_title(title)
411
+ return false unless windows?
412
+
413
+ # Convert to UTF-16LE with null terminator
414
+ wide_title = (title + "\0").encode("UTF-16LE")
415
+ SetConsoleTitleW(Fiddle::Pointer[wide_title]) != 0
416
+ end
417
+
418
+ # Show the cursor
419
+ # @param handle [Integer] Console handle (defaults to stdout)
420
+ # @return [Boolean] Success status
421
+ def show_cursor(handle = stdout_handle)
422
+ set_cursor_visibility(true, handle)
423
+ end
424
+
425
+ # Hide the cursor
426
+ # @param handle [Integer] Console handle (defaults to stdout)
427
+ # @return [Boolean] Success status
428
+ def hide_cursor(handle = stdout_handle)
429
+ set_cursor_visibility(false, handle)
430
+ end
431
+
432
+ # Set cursor visibility
433
+ # @param visible [Boolean] Whether cursor should be visible
434
+ # @param handle [Integer] Console handle (defaults to stdout)
435
+ # @return [Boolean] Success status
436
+ def set_cursor_visibility(visible, handle = stdout_handle)
437
+ return false unless windows? && handle
438
+
439
+ # Get current cursor info
440
+ buffer = Fiddle::Pointer.malloc(CONSOLE_CURSOR_INFO_SIZE, Fiddle::RUBY_FREE)
441
+ result = GetConsoleCursorInfo(handle, buffer)
442
+ return false if result == 0
443
+
444
+ # Modify visibility
445
+ data = buffer[0, CONSOLE_CURSOR_INFO_SIZE].unpack("L L")
446
+ cursor_size = data[0]
447
+ buffer[0, CONSOLE_CURSOR_INFO_SIZE] = [cursor_size, visible ? 1 : 0].pack("L L")
448
+
449
+ SetConsoleCursorInfo(handle, buffer) != 0
450
+ end
451
+
452
+ # Write text to console (bypassing Ruby's IO buffering)
453
+ # @param text [String] Text to write
454
+ # @param handle [Integer] Console handle (defaults to stdout)
455
+ # @return [Integer, nil] Number of characters written or nil on failure
456
+ def write_console(text, handle = stdout_handle)
457
+ return nil unless windows? && handle
458
+
459
+ wide_text = text.encode("UTF-16LE")
460
+ char_count = text.length
461
+ written_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
462
+
463
+ result = WriteConsoleW(handle, Fiddle::Pointer[wide_text], char_count, written_ptr, nil)
464
+ return nil if result == 0
465
+
466
+ written_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
467
+ end
468
+
469
+ # Clear the entire screen
470
+ # @param handle [Integer] Console handle (defaults to stdout)
471
+ # @return [Boolean] Success status
472
+ def clear_screen(handle = stdout_handle)
473
+ return false unless windows? && handle
474
+
475
+ info = get_screen_buffer_info(handle)
476
+ return false unless info
477
+
478
+ size = info[:size][:width] * info[:size][:height]
479
+ attributes = info[:attributes]
480
+
481
+ fill_output_character(" ", size, 0, 0, handle)
482
+ fill_output_attribute(attributes, size, 0, 0, handle)
483
+ set_cursor_position(0, 0, handle)
484
+
485
+ true
486
+ end
487
+
488
+ # Erase from cursor to end of line
489
+ # @param handle [Integer] Console handle (defaults to stdout)
490
+ # @return [Boolean] Success status
491
+ def erase_line(handle = stdout_handle)
492
+ return false unless windows? && handle
493
+
494
+ info = get_screen_buffer_info(handle)
495
+ return false unless info
496
+
497
+ x = info[:cursor_position][:x]
498
+ y = info[:cursor_position][:y]
499
+ length = info[:size][:width] - x
500
+ attributes = info[:attributes]
501
+
502
+ fill_output_character(" ", length, x, y, handle)
503
+ fill_output_attribute(attributes, length, x, y, handle)
504
+
505
+ true
506
+ end
507
+
508
+ # Move cursor up
509
+ # @param lines [Integer] Number of lines to move up
510
+ # @param handle [Integer] Console handle (defaults to stdout)
511
+ # @return [Boolean] Success status
512
+ def cursor_up(lines = 1, handle = stdout_handle)
513
+ return false unless windows?
514
+
515
+ pos = get_cursor_position
516
+ return false unless pos
517
+
518
+ new_y = [pos[1] - lines, 0].max
519
+ set_cursor_position(pos[0], new_y, handle)
520
+ end
521
+
522
+ # Move cursor down
523
+ # @param lines [Integer] Number of lines to move down
524
+ # @param handle [Integer] Console handle (defaults to stdout)
525
+ # @return [Boolean] Success status
526
+ def cursor_down(lines = 1, handle = stdout_handle)
527
+ return false unless windows?
528
+
529
+ info = get_screen_buffer_info(handle)
530
+ return false unless info
531
+
532
+ pos = get_cursor_position
533
+ return false unless pos
534
+
535
+ max_y = info[:size][:height] - 1
536
+ new_y = [pos[1] + lines, max_y].min
537
+ set_cursor_position(pos[0], new_y, handle)
538
+ end
539
+
540
+ # Move cursor forward (right)
541
+ # @param columns [Integer] Number of columns to move
542
+ # @param handle [Integer] Console handle (defaults to stdout)
543
+ # @return [Boolean] Success status
544
+ def cursor_forward(columns = 1, handle = stdout_handle)
545
+ return false unless windows?
546
+
547
+ info = get_screen_buffer_info(handle)
548
+ return false unless info
549
+
550
+ pos = get_cursor_position
551
+ return false unless pos
552
+
553
+ max_x = info[:size][:width] - 1
554
+ new_x = [pos[0] + columns, max_x].min
555
+ set_cursor_position(new_x, pos[1], handle)
556
+ end
557
+
558
+ # Move cursor backward (left)
559
+ # @param columns [Integer] Number of columns to move
560
+ # @param handle [Integer] Console handle (defaults to stdout)
561
+ # @return [Boolean] Success status
562
+ def cursor_backward(columns = 1, handle = stdout_handle)
563
+ return false unless windows?
564
+
565
+ pos = get_cursor_position
566
+ return false unless pos
567
+
568
+ new_x = [pos[0] - columns, 0].max
569
+ set_cursor_position(new_x, pos[1], handle)
570
+ end
571
+
572
+ # Move cursor to the beginning of the line
573
+ # @param handle [Integer] Console handle (defaults to stdout)
574
+ # @return [Boolean] Success status
575
+ def cursor_to_column(column = 0, handle = stdout_handle)
576
+ return false unless windows?
577
+
578
+ pos = get_cursor_position
579
+ return false unless pos
580
+
581
+ set_cursor_position(column, pos[1], handle)
582
+ end
583
+
584
+ # Convert ANSI color number to Windows console attributes
585
+ # @param foreground [Integer, nil] ANSI foreground color (0-15)
586
+ # @param background [Integer, nil] ANSI background color (0-15)
587
+ # @return [Integer] Windows console attribute value
588
+ def ansi_to_windows_attributes(foreground: nil, background: nil)
589
+ attributes = 0
590
+ attributes |= ANSI_TO_WINDOWS_FG[foreground] if foreground && foreground < 16
591
+ attributes |= ANSI_TO_WINDOWS_BG[background] if background && background < 16
592
+ attributes
593
+ end
594
+
595
+ # =========================================================================
596
+ # Screen Buffer Capture Methods (for terminal recording)
597
+ # =========================================================================
598
+
599
+ # CHAR_INFO structure:
600
+ # typedef struct _CHAR_INFO {
601
+ # union { WCHAR UnicodeChar; CHAR AsciiChar; } Char; // 2 bytes
602
+ # WORD Attributes; // 2 bytes
603
+ # } CHAR_INFO;
604
+ CHAR_INFO_SIZE = 4
605
+
606
+ # SMALL_RECT structure for region specification
607
+ SMALL_RECT_SIZE = 8
608
+
609
+ # Mapping from Windows console foreground attributes to ANSI color numbers
610
+ WINDOWS_FG_TO_ANSI = {
611
+ 0 => 30, # Black
612
+ FOREGROUND_RED => 31, # Red
613
+ FOREGROUND_GREEN => 32, # Green
614
+ (FOREGROUND_RED | FOREGROUND_GREEN) => 33, # Yellow
615
+ FOREGROUND_BLUE => 34, # Blue
616
+ (FOREGROUND_RED | FOREGROUND_BLUE) => 35, # Magenta
617
+ (FOREGROUND_GREEN | FOREGROUND_BLUE) => 36, # Cyan
618
+ (FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE) => 37 # White
619
+ }.freeze
620
+
621
+ # Mapping from Windows console background attributes to ANSI color numbers
622
+ WINDOWS_BG_TO_ANSI = {
623
+ 0 => 40, # Black
624
+ BACKGROUND_RED => 41, # Red
625
+ BACKGROUND_GREEN => 42, # Green
626
+ (BACKGROUND_RED | BACKGROUND_GREEN) => 43, # Yellow
627
+ BACKGROUND_BLUE => 44, # Blue
628
+ (BACKGROUND_RED | BACKGROUND_BLUE) => 45, # Magenta
629
+ (BACKGROUND_GREEN | BACKGROUND_BLUE) => 46, # Cyan
630
+ (BACKGROUND_RED | BACKGROUND_GREEN | BACKGROUND_BLUE) => 47 # White
631
+ }.freeze
632
+
633
+ # Read characters from a line in the screen buffer
634
+ #
635
+ # @param row [Integer] Row number (0-indexed)
636
+ # @param start_col [Integer] Starting column (0-indexed)
637
+ # @param length [Integer] Number of characters to read
638
+ # @param handle [Integer] Console handle (defaults to stdout)
639
+ # @return [String, nil] Characters read or nil on failure
640
+ def read_line_characters(row, start_col, length, handle = stdout_handle)
641
+ return nil unless windows? && handle
642
+
643
+ # Allocate buffer for UTF-16LE characters (2 bytes per char)
644
+ buffer = Fiddle::Pointer.malloc(length * 2, Fiddle::RUBY_FREE)
645
+ chars_read_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
646
+
647
+ # Pack COORD as DWORD (low word = X, high word = Y)
648
+ coord = (row << 16) | (start_col & 0xFFFF)
649
+
650
+ result = ReadConsoleOutputCharacterW(handle, buffer, length, coord, chars_read_ptr)
651
+ return nil if result == 0
652
+
653
+ chars_read = chars_read_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
654
+ return "" if chars_read == 0
655
+
656
+ # Convert UTF-16LE to Ruby string
657
+ buffer[0, chars_read * 2].force_encoding("UTF-16LE").encode("UTF-8")
658
+ end
659
+
660
+ # Read attributes from a line in the screen buffer
661
+ #
662
+ # @param row [Integer] Row number (0-indexed)
663
+ # @param start_col [Integer] Starting column (0-indexed)
664
+ # @param length [Integer] Number of attributes to read
665
+ # @param handle [Integer] Console handle (defaults to stdout)
666
+ # @return [Array<Integer>, nil] Attribute values or nil on failure
667
+ def read_line_attributes(row, start_col, length, handle = stdout_handle)
668
+ return nil unless windows? && handle
669
+
670
+ # Allocate buffer for WORD attributes (2 bytes each)
671
+ buffer = Fiddle::Pointer.malloc(length * 2, Fiddle::RUBY_FREE)
672
+ attrs_read_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
673
+
674
+ # Pack COORD as DWORD
675
+ coord = (row << 16) | (start_col & 0xFFFF)
676
+
677
+ result = ReadConsoleOutputAttribute(handle, buffer, length, coord, attrs_read_ptr)
678
+ return nil if result == 0
679
+
680
+ attrs_read = attrs_read_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
681
+ return [] if attrs_read == 0
682
+
683
+ # Unpack as array of unsigned shorts
684
+ buffer[0, attrs_read * 2].unpack("S*")
685
+ end
686
+
687
+ # Read a rectangular region from the screen buffer
688
+ #
689
+ # @param left [Integer] Left column (0-indexed)
690
+ # @param top [Integer] Top row (0-indexed)
691
+ # @param right [Integer] Right column (0-indexed, inclusive)
692
+ # @param bottom [Integer] Bottom row (0-indexed, inclusive)
693
+ # @param handle [Integer] Console handle (defaults to stdout)
694
+ # @return [Array<Array<Hash>>, nil] 2D array of {char:, attributes:} or nil on failure
695
+ def read_screen_region(left, top, right, bottom, handle = stdout_handle)
696
+ return nil unless windows? && handle
697
+
698
+ width = right - left + 1
699
+ height = bottom - top + 1
700
+ total_cells = width * height
701
+
702
+ # Allocate CHAR_INFO buffer
703
+ buffer = Fiddle::Pointer.malloc(total_cells * CHAR_INFO_SIZE, Fiddle::RUBY_FREE)
704
+
705
+ # Pack buffer size COORD (width, height)
706
+ buffer_size = (height << 16) | (width & 0xFFFF)
707
+
708
+ # Pack buffer coord (0, 0) - start of destination buffer
709
+ buffer_coord = 0
710
+
711
+ # Pack SMALL_RECT (left, top, right, bottom)
712
+ region_buffer = Fiddle::Pointer.malloc(SMALL_RECT_SIZE, Fiddle::RUBY_FREE)
713
+ region_buffer[0, SMALL_RECT_SIZE] = [left, top, right, bottom].pack("s4")
714
+
715
+ result = ReadConsoleOutputW(handle, buffer, buffer_size, buffer_coord, region_buffer)
716
+ return nil if result == 0
717
+
718
+ # Parse CHAR_INFO structures into 2D array
719
+ cells = []
720
+ height.times do |row|
721
+ row_cells = []
722
+ width.times do |col|
723
+ offset = (row * width + col) * CHAR_INFO_SIZE
724
+ char_data = buffer[offset, CHAR_INFO_SIZE]
725
+
726
+ # CHAR_INFO: 2 bytes unicode char, 2 bytes attributes
727
+ unicode_char, attributes = char_data.unpack("S S")
728
+
729
+ row_cells << {
730
+ char: [unicode_char].pack("U*"),
731
+ attributes: attributes
732
+ }
733
+ end
734
+ cells << row_cells
735
+ end
736
+
737
+ cells
738
+ end
739
+
740
+ # Capture the entire visible screen buffer
741
+ #
742
+ # @param handle [Integer] Console handle (defaults to stdout)
743
+ # @return [Hash, nil] Screen buffer data or nil on failure
744
+ # - :width [Integer] Screen width
745
+ # - :height [Integer] Screen height
746
+ # - :cursor_x [Integer] Cursor X position
747
+ # - :cursor_y [Integer] Cursor Y position
748
+ # - :lines [Array<Hash>] Array of line data
749
+ # - :chars [String] Characters in the line
750
+ # - :attributes [Array<Integer>] Attribute for each character
751
+ def capture_screen_buffer(handle = stdout_handle)
752
+ return nil unless windows? && handle
753
+
754
+ info = get_screen_buffer_info(handle)
755
+ return nil unless info
756
+
757
+ window = info[:window]
758
+ width = window[:right] - window[:left] + 1
759
+ height = window[:bottom] - window[:top] + 1
760
+
761
+ lines = []
762
+ height.times do |row|
763
+ actual_row = window[:top] + row
764
+ chars = read_line_characters(actual_row, window[:left], width, handle)
765
+ attributes = read_line_attributes(actual_row, window[:left], width, handle)
766
+
767
+ lines << {
768
+ chars: chars || "",
769
+ attributes: attributes || []
770
+ }
771
+ end
772
+
773
+ {
774
+ width: width,
775
+ height: height,
776
+ cursor_x: info[:cursor_position][:x] - window[:left],
777
+ cursor_y: info[:cursor_position][:y] - window[:top],
778
+ lines: lines
779
+ }
780
+ end
781
+
782
+ # Convert Windows console attributes to ANSI SGR codes
783
+ #
784
+ # @param attributes [Integer] Windows console attribute value
785
+ # @return [String] ANSI escape sequence for the given attributes
786
+ def windows_attr_to_ansi(attributes)
787
+ codes = []
788
+
789
+ # Extract foreground color (bits 0-3)
790
+ fg_color = attributes & 0x0F
791
+ fg_intense = (fg_color & FOREGROUND_INTENSITY) != 0
792
+ fg_base = fg_color & 0x07
793
+
794
+ # Map to ANSI foreground color
795
+ ansi_fg = WINDOWS_FG_TO_ANSI[fg_base]
796
+ ansi_fg += 60 if fg_intense && ansi_fg # Bright variant (90-97)
797
+ codes << ansi_fg if ansi_fg
798
+
799
+ # Extract background color (bits 4-7)
800
+ bg_color = (attributes >> 4) & 0x0F
801
+ bg_intense = (bg_color & 0x08) != 0
802
+ bg_base = (bg_color & 0x07) << 4 # Shift back to match WINDOWS_BG_TO_ANSI keys
803
+
804
+ # Map to ANSI background color
805
+ ansi_bg = WINDOWS_BG_TO_ANSI[bg_base]
806
+ ansi_bg += 60 if bg_intense && ansi_bg # Bright variant (100-107)
807
+ codes << ansi_bg if ansi_bg
808
+
809
+ # Handle other attributes
810
+ codes << 4 if (attributes & COMMON_LVB_UNDERSCORE) != 0 # Underline
811
+ codes << 7 if (attributes & COMMON_LVB_REVERSE_VIDEO) != 0 # Reverse
812
+
813
+ return "" if codes.empty?
814
+
815
+ "\e[#{codes.join(";")}m"
816
+ end
817
+
818
+ # Convert screen buffer capture to ANSI escape sequence string
819
+ #
820
+ # @param buffer_data [Hash] Data from capture_screen_buffer
821
+ # @return [String] ANSI escape sequence string representing the screen content
822
+ def buffer_to_ansi(buffer_data)
823
+ return "" unless buffer_data
824
+
825
+ output = StringIO.new
826
+
827
+ buffer_data[:lines].each_with_index do |line, row|
828
+ chars = line[:chars]
829
+ attributes = line[:attributes]
830
+
831
+ last_attr = nil
832
+ chars.each_char.with_index do |char, col|
833
+ attr = attributes[col] || 0
834
+
835
+ # Emit ANSI code if attributes changed
836
+ if attr != last_attr
837
+ output << "\e[0m" if last_attr # Reset first
838
+ ansi = windows_attr_to_ansi(attr)
839
+ output << ansi unless ansi.empty?
840
+ last_attr = attr
841
+ end
842
+
843
+ output << char
844
+ end
845
+
846
+ # Reset and newline (except for last line)
847
+ output << "\e[0m" if last_attr
848
+ output << "\r\n" if row < buffer_data[:lines].length - 1
849
+ end
850
+
851
+ output.string
852
+ end
853
+ end
854
+
855
+ # Auto-enable ANSI on Windows when this module is loaded
856
+ enable_ansi! if windows?
857
+ end
858
+ end
859
+