rich-ruby 1.0.1 → 1.0.2

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.
@@ -1,582 +1,620 @@
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
- end
143
-
144
- # CONSOLE_SCREEN_BUFFER_INFO structure layout:
145
- # typedef struct _CONSOLE_SCREEN_BUFFER_INFO {
146
- # COORD dwSize; // 4 bytes (2x SHORT)
147
- # COORD dwCursorPosition; // 4 bytes (2x SHORT)
148
- # WORD wAttributes; // 2 bytes
149
- # SMALL_RECT srWindow; // 8 bytes (4x SHORT)
150
- # COORD dwMaximumWindowSize; // 4 bytes (2x SHORT)
151
- # } CONSOLE_SCREEN_BUFFER_INFO;
152
- # Total: 22 bytes
153
- CONSOLE_SCREEN_BUFFER_INFO_SIZE = 22
154
-
155
- # CONSOLE_CURSOR_INFO structure layout:
156
- # typedef struct _CONSOLE_CURSOR_INFO {
157
- # DWORD dwSize; // 4 bytes
158
- # BOOL bVisible; // 4 bytes
159
- # } CONSOLE_CURSOR_INFO;
160
- # Total: 8 bytes
161
- CONSOLE_CURSOR_INFO_SIZE = 8
162
-
163
- class << self
164
- # @return [Boolean] Whether the current platform is Windows
165
- def windows?
166
- Gem.win_platform?
167
- end
168
-
169
- # @return [Integer] Handle to stdout
170
- def stdout_handle
171
- return nil unless windows?
172
- @stdout_handle ||= GetStdHandle(STD_OUTPUT_HANDLE)
173
- end
174
-
175
- # @return [Integer] Handle to stdin
176
- def stdin_handle
177
- return nil unless windows?
178
- @stdin_handle ||= GetStdHandle(STD_INPUT_HANDLE)
179
- end
180
-
181
- # @return [Integer] Handle to stderr
182
- def stderr_handle
183
- return nil unless windows?
184
- @stderr_handle ||= GetStdHandle(STD_ERROR_HANDLE)
185
- end
186
-
187
- # Get the current console mode for a handle
188
- # @param handle [Integer] Console handle (defaults to stdout)
189
- # @return [Integer, nil] Console mode flags or nil on failure
190
- def get_console_mode(handle = stdout_handle)
191
- return nil unless windows? && handle
192
-
193
- mode_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
194
- result = GetConsoleMode(handle, mode_ptr)
195
- return nil if result == 0
196
-
197
- mode_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
198
- end
199
-
200
- # Set the console mode for a handle
201
- # @param mode [Integer] Console mode flags
202
- # @param handle [Integer] Console handle (defaults to stdout)
203
- # @return [Boolean] Success status
204
- def set_console_mode(mode, handle = stdout_handle)
205
- return false unless windows? && handle
206
-
207
- SetConsoleMode(handle, mode) != 0
208
- end
209
-
210
- # Check if virtual terminal (ANSI) processing is supported
211
- # @return [Boolean] True if ANSI escape sequences are supported
212
- def supports_ansi?
213
- return @supports_ansi if defined?(@supports_ansi)
214
-
215
- unless windows?
216
- @supports_ansi = true # Unix terminals support ANSI
217
- return @supports_ansi
218
- end
219
-
220
- mode = get_console_mode
221
- return @supports_ansi = false if mode.nil?
222
-
223
- @supports_ansi = (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0
224
- end
225
-
226
- # Enable virtual terminal (ANSI) processing
227
- # @return [Boolean] True if ANSI mode was successfully enabled
228
- def enable_ansi!
229
- return true unless windows? # Already supported on Unix
230
-
231
- handle = stdout_handle
232
- return false unless handle
233
-
234
- current_mode = get_console_mode(handle)
235
- return false unless current_mode
236
-
237
- new_mode = current_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING
238
- result = set_console_mode(new_mode, handle)
239
-
240
- # Update cached value
241
- @supports_ansi = result
242
- result
243
- end
244
-
245
- # Disable virtual terminal (ANSI) processing
246
- # @return [Boolean] True if ANSI mode was successfully disabled
247
- def disable_ansi!
248
- return false unless windows?
249
-
250
- handle = stdout_handle
251
- return false unless handle
252
-
253
- current_mode = get_console_mode(handle)
254
- return false unless current_mode
255
-
256
- new_mode = current_mode & ~ENABLE_VIRTUAL_TERMINAL_PROCESSING
257
- result = set_console_mode(new_mode, handle)
258
-
259
- @supports_ansi = !result if result
260
- result
261
- end
262
-
263
- # Get console screen buffer info
264
- # @param handle [Integer] Console handle (defaults to stdout)
265
- # @return [Hash, nil] Screen buffer info or nil on failure
266
- def get_screen_buffer_info(handle = stdout_handle)
267
- return nil unless windows? && handle
268
-
269
- buffer = Fiddle::Pointer.malloc(CONSOLE_SCREEN_BUFFER_INFO_SIZE, Fiddle::RUBY_FREE)
270
- result = GetConsoleScreenBufferInfo(handle, buffer)
271
- return nil if result == 0
272
-
273
- data = buffer[0, CONSOLE_SCREEN_BUFFER_INFO_SIZE]
274
-
275
- # Unpack the structure
276
- values = data.unpack("s2 s2 S s4 s2")
277
-
278
- {
279
- size: { width: values[0], height: values[1] },
280
- cursor_position: { x: values[2], y: values[3] },
281
- attributes: values[4],
282
- window: {
283
- left: values[5],
284
- top: values[6],
285
- right: values[7],
286
- bottom: values[8]
287
- },
288
- max_window_size: { width: values[9], height: values[10] }
289
- }
290
- end
291
-
292
- # Get the console window dimensions
293
- # @return [Array<Integer>, nil] [width, height] or nil on failure
294
- def get_size
295
- return nil unless windows?
296
-
297
- info = get_screen_buffer_info
298
- return nil unless info
299
-
300
- width = info[:window][:right] - info[:window][:left] + 1
301
- height = info[:window][:bottom] - info[:window][:top] + 1
302
-
303
- [width, height]
304
- end
305
-
306
- # Get current cursor position
307
- # @return [Array<Integer>, nil] [x, y] or nil on failure
308
- def get_cursor_position
309
- return nil unless windows?
310
-
311
- info = get_screen_buffer_info
312
- return nil unless info
313
-
314
- [info[:cursor_position][:x], info[:cursor_position][:y]]
315
- end
316
-
317
- # Set cursor position
318
- # @param x [Integer] Column (0-indexed)
319
- # @param y [Integer] Row (0-indexed)
320
- # @param handle [Integer] Console handle (defaults to stdout)
321
- # @return [Boolean] Success status
322
- def set_cursor_position(x, y, handle = stdout_handle)
323
- return false unless windows? && handle
324
-
325
- # Pack COORD structure as DWORD (low word = X, high word = Y)
326
- coord = (y << 16) | (x & 0xFFFF)
327
- SetConsoleCursorPosition(handle, coord) != 0
328
- end
329
-
330
- # Set console text attributes (foreground/background colors)
331
- # @param attributes [Integer] Attribute flags
332
- # @param handle [Integer] Console handle (defaults to stdout)
333
- # @return [Boolean] Success status
334
- def set_text_attribute(attributes, handle = stdout_handle)
335
- return false unless windows? && handle
336
-
337
- SetConsoleTextAttribute(handle, attributes) != 0
338
- end
339
-
340
- # Get current text attributes
341
- # @return [Integer, nil] Current attributes or nil on failure
342
- def get_text_attributes
343
- return nil unless windows?
344
-
345
- info = get_screen_buffer_info
346
- return nil unless info
347
-
348
- info[:attributes]
349
- end
350
-
351
- # Fill console output with a character
352
- # @param char [String] Character to fill with
353
- # @param length [Integer] Number of cells to fill
354
- # @param x [Integer] Starting column
355
- # @param y [Integer] Starting row
356
- # @param handle [Integer] Console handle (defaults to stdout)
357
- # @return [Integer, nil] Number of characters written or nil on failure
358
- def fill_output_character(char, length, x, y, handle = stdout_handle)
359
- return nil unless windows? && handle
360
-
361
- coord = (y << 16) | (x & 0xFFFF)
362
- written_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
363
-
364
- char_code = char.ord
365
- result = FillConsoleOutputCharacterW(handle, char_code, length, coord, written_ptr)
366
- return nil if result == 0
367
-
368
- written_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
369
- end
370
-
371
- # Fill console output with an attribute
372
- # @param attribute [Integer] Attribute to fill with
373
- # @param length [Integer] Number of cells to fill
374
- # @param x [Integer] Starting column
375
- # @param y [Integer] Starting row
376
- # @param handle [Integer] Console handle (defaults to stdout)
377
- # @return [Integer, nil] Number of cells written or nil on failure
378
- def fill_output_attribute(attribute, length, x, y, handle = stdout_handle)
379
- return nil unless windows? && handle
380
-
381
- coord = (y << 16) | (x & 0xFFFF)
382
- written_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
383
-
384
- result = FillConsoleOutputAttribute(handle, attribute, length, coord, written_ptr)
385
- return nil if result == 0
386
-
387
- written_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
388
- end
389
-
390
- # Set console window title
391
- # @param title [String] New window title
392
- # @return [Boolean] Success status
393
- def set_title(title)
394
- return false unless windows?
395
-
396
- # Convert to UTF-16LE with null terminator
397
- wide_title = (title + "\0").encode("UTF-16LE")
398
- SetConsoleTitleW(Fiddle::Pointer[wide_title]) != 0
399
- end
400
-
401
- # Show the cursor
402
- # @param handle [Integer] Console handle (defaults to stdout)
403
- # @return [Boolean] Success status
404
- def show_cursor(handle = stdout_handle)
405
- set_cursor_visibility(true, handle)
406
- end
407
-
408
- # Hide the cursor
409
- # @param handle [Integer] Console handle (defaults to stdout)
410
- # @return [Boolean] Success status
411
- def hide_cursor(handle = stdout_handle)
412
- set_cursor_visibility(false, handle)
413
- end
414
-
415
- # Set cursor visibility
416
- # @param visible [Boolean] Whether cursor should be visible
417
- # @param handle [Integer] Console handle (defaults to stdout)
418
- # @return [Boolean] Success status
419
- def set_cursor_visibility(visible, handle = stdout_handle)
420
- return false unless windows? && handle
421
-
422
- # Get current cursor info
423
- buffer = Fiddle::Pointer.malloc(CONSOLE_CURSOR_INFO_SIZE, Fiddle::RUBY_FREE)
424
- result = GetConsoleCursorInfo(handle, buffer)
425
- return false if result == 0
426
-
427
- # Modify visibility
428
- data = buffer[0, CONSOLE_CURSOR_INFO_SIZE].unpack("L L")
429
- cursor_size = data[0]
430
- buffer[0, CONSOLE_CURSOR_INFO_SIZE] = [cursor_size, visible ? 1 : 0].pack("L L")
431
-
432
- SetConsoleCursorInfo(handle, buffer) != 0
433
- end
434
-
435
- # Write text to console (bypassing Ruby's IO buffering)
436
- # @param text [String] Text to write
437
- # @param handle [Integer] Console handle (defaults to stdout)
438
- # @return [Integer, nil] Number of characters written or nil on failure
439
- def write_console(text, handle = stdout_handle)
440
- return nil unless windows? && handle
441
-
442
- wide_text = text.encode("UTF-16LE")
443
- char_count = text.length
444
- written_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
445
-
446
- result = WriteConsoleW(handle, Fiddle::Pointer[wide_text], char_count, written_ptr, nil)
447
- return nil if result == 0
448
-
449
- written_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
450
- end
451
-
452
- # Clear the entire screen
453
- # @param handle [Integer] Console handle (defaults to stdout)
454
- # @return [Boolean] Success status
455
- def clear_screen(handle = stdout_handle)
456
- return false unless windows? && handle
457
-
458
- info = get_screen_buffer_info(handle)
459
- return false unless info
460
-
461
- size = info[:size][:width] * info[:size][:height]
462
- attributes = info[:attributes]
463
-
464
- fill_output_character(" ", size, 0, 0, handle)
465
- fill_output_attribute(attributes, size, 0, 0, handle)
466
- set_cursor_position(0, 0, handle)
467
-
468
- true
469
- end
470
-
471
- # Erase from cursor to end of line
472
- # @param handle [Integer] Console handle (defaults to stdout)
473
- # @return [Boolean] Success status
474
- def erase_line(handle = stdout_handle)
475
- return false unless windows? && handle
476
-
477
- info = get_screen_buffer_info(handle)
478
- return false unless info
479
-
480
- x = info[:cursor_position][:x]
481
- y = info[:cursor_position][:y]
482
- length = info[:size][:width] - x
483
- attributes = info[:attributes]
484
-
485
- fill_output_character(" ", length, x, y, handle)
486
- fill_output_attribute(attributes, length, x, y, handle)
487
-
488
- true
489
- end
490
-
491
- # Move cursor up
492
- # @param lines [Integer] Number of lines to move up
493
- # @param handle [Integer] Console handle (defaults to stdout)
494
- # @return [Boolean] Success status
495
- def cursor_up(lines = 1, handle = stdout_handle)
496
- return false unless windows?
497
-
498
- pos = get_cursor_position
499
- return false unless pos
500
-
501
- new_y = [pos[1] - lines, 0].max
502
- set_cursor_position(pos[0], new_y, handle)
503
- end
504
-
505
- # Move cursor down
506
- # @param lines [Integer] Number of lines to move down
507
- # @param handle [Integer] Console handle (defaults to stdout)
508
- # @return [Boolean] Success status
509
- def cursor_down(lines = 1, handle = stdout_handle)
510
- return false unless windows?
511
-
512
- info = get_screen_buffer_info(handle)
513
- return false unless info
514
-
515
- pos = get_cursor_position
516
- return false unless pos
517
-
518
- max_y = info[:size][:height] - 1
519
- new_y = [pos[1] + lines, max_y].min
520
- set_cursor_position(pos[0], new_y, handle)
521
- end
522
-
523
- # Move cursor forward (right)
524
- # @param columns [Integer] Number of columns to move
525
- # @param handle [Integer] Console handle (defaults to stdout)
526
- # @return [Boolean] Success status
527
- def cursor_forward(columns = 1, handle = stdout_handle)
528
- return false unless windows?
529
-
530
- info = get_screen_buffer_info(handle)
531
- return false unless info
532
-
533
- pos = get_cursor_position
534
- return false unless pos
535
-
536
- max_x = info[:size][:width] - 1
537
- new_x = [pos[0] + columns, max_x].min
538
- set_cursor_position(new_x, pos[1], handle)
539
- end
540
-
541
- # Move cursor backward (left)
542
- # @param columns [Integer] Number of columns to move
543
- # @param handle [Integer] Console handle (defaults to stdout)
544
- # @return [Boolean] Success status
545
- def cursor_backward(columns = 1, handle = stdout_handle)
546
- return false unless windows?
547
-
548
- pos = get_cursor_position
549
- return false unless pos
550
-
551
- new_x = [pos[0] - columns, 0].max
552
- set_cursor_position(new_x, pos[1], handle)
553
- end
554
-
555
- # Move cursor to the beginning of the line
556
- # @param handle [Integer] Console handle (defaults to stdout)
557
- # @return [Boolean] Success status
558
- def cursor_to_column(column = 0, handle = stdout_handle)
559
- return false unless windows?
560
-
561
- pos = get_cursor_position
562
- return false unless pos
563
-
564
- set_cursor_position(column, pos[1], handle)
565
- end
566
-
567
- # Convert ANSI color number to Windows console attributes
568
- # @param foreground [Integer, nil] ANSI foreground color (0-15)
569
- # @param background [Integer, nil] ANSI background color (0-15)
570
- # @return [Integer] Windows console attribute value
571
- def ansi_to_windows_attributes(foreground: nil, background: nil)
572
- attributes = 0
573
- attributes |= ANSI_TO_WINDOWS_FG[foreground] if foreground && foreground < 16
574
- attributes |= ANSI_TO_WINDOWS_BG[background] if background && background < 16
575
- attributes
576
- end
577
- end
578
-
579
- # Auto-enable ANSI on Windows when this module is loaded
580
- enable_ansi! if windows?
581
- end
582
- end
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
+ # DWORD WINAPI GetLastError(void)
144
+ extern "unsigned long GetLastError()"
145
+ end
146
+
147
+ # Sentinel returned by GetStdHandle on failure.
148
+ INVALID_HANDLE_VALUE = (1 << (Fiddle::SIZEOF_VOIDP * 8)) - 1
149
+
150
+ # CONSOLE_SCREEN_BUFFER_INFO structure layout:
151
+ # typedef struct _CONSOLE_SCREEN_BUFFER_INFO {
152
+ # COORD dwSize; // 4 bytes (2x SHORT)
153
+ # COORD dwCursorPosition; // 4 bytes (2x SHORT)
154
+ # WORD wAttributes; // 2 bytes
155
+ # SMALL_RECT srWindow; // 8 bytes (4x SHORT)
156
+ # COORD dwMaximumWindowSize; // 4 bytes (2x SHORT)
157
+ # } CONSOLE_SCREEN_BUFFER_INFO;
158
+ # Total: 22 bytes
159
+ CONSOLE_SCREEN_BUFFER_INFO_SIZE = 22
160
+
161
+ # CONSOLE_CURSOR_INFO structure layout:
162
+ # typedef struct _CONSOLE_CURSOR_INFO {
163
+ # DWORD dwSize; // 4 bytes
164
+ # BOOL bVisible; // 4 bytes
165
+ # } CONSOLE_CURSOR_INFO;
166
+ # Total: 8 bytes
167
+ CONSOLE_CURSOR_INFO_SIZE = 8
168
+
169
+ class << self
170
+ # @return [Boolean] Whether the current platform is Windows
171
+ def windows?
172
+ Gem.win_platform?
173
+ end
174
+
175
+ # @return [Integer, nil] Handle to stdout, or nil if unavailable
176
+ def stdout_handle
177
+ return nil unless windows?
178
+ @stdout_handle ||= valid_handle(GetStdHandle(STD_OUTPUT_HANDLE))
179
+ end
180
+
181
+ # @return [Integer, nil] Handle to stdin, or nil if unavailable
182
+ def stdin_handle
183
+ return nil unless windows?
184
+ @stdin_handle ||= valid_handle(GetStdHandle(STD_INPUT_HANDLE))
185
+ end
186
+
187
+ # @return [Integer, nil] Handle to stderr, or nil if unavailable
188
+ def stderr_handle
189
+ return nil unless windows?
190
+ @stderr_handle ||= valid_handle(GetStdHandle(STD_ERROR_HANDLE))
191
+ end
192
+
193
+ # @return [Integer] The calling thread's last Win32 error code (0 if none)
194
+ def last_error
195
+ return 0 unless windows?
196
+
197
+ GetLastError()
198
+ end
199
+
200
+ # Normalize a raw GetStdHandle result: NULL (0) means "no such handle"
201
+ # (e.g. output redirected to a pipe in a GUI process) and
202
+ # INVALID_HANDLE_VALUE means an error. Both become nil so callers and the
203
+ # `&& handle` guards short-circuit instead of issuing API calls against a
204
+ # bad handle. A failed lookup is intentionally NOT memoized.
205
+ def valid_handle(handle)
206
+ return nil if handle.nil?
207
+
208
+ value = handle.respond_to?(:to_i) ? handle.to_i : handle
209
+ return nil if value.zero? || value == INVALID_HANDLE_VALUE
210
+
211
+ handle
212
+ end
213
+
214
+ # Get the current console mode for a handle
215
+ # @param handle [Integer] Console handle (defaults to stdout)
216
+ # @return [Integer, nil] Console mode flags or nil on failure
217
+ def get_console_mode(handle = stdout_handle)
218
+ return nil unless windows? && handle
219
+
220
+ mode_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
221
+ result = GetConsoleMode(handle, mode_ptr)
222
+ return nil if result == 0
223
+
224
+ mode_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
225
+ end
226
+
227
+ # Set the console mode for a handle
228
+ # @param mode [Integer] Console mode flags
229
+ # @param handle [Integer] Console handle (defaults to stdout)
230
+ # @return [Boolean] Success status
231
+ def set_console_mode(mode, handle = stdout_handle)
232
+ return false unless windows? && handle
233
+
234
+ SetConsoleMode(handle, mode) != 0
235
+ end
236
+
237
+ # Check if virtual terminal (ANSI) processing is supported
238
+ # @return [Boolean] True if ANSI escape sequences are supported
239
+ def supports_ansi?
240
+ return @supports_ansi if defined?(@supports_ansi)
241
+
242
+ unless windows?
243
+ @supports_ansi = true # Unix terminals support ANSI
244
+ return @supports_ansi
245
+ end
246
+
247
+ mode = get_console_mode
248
+ return @supports_ansi = false if mode.nil?
249
+
250
+ @supports_ansi = (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0
251
+ end
252
+
253
+ # Enable virtual terminal (ANSI) processing
254
+ # @return [Boolean] True if ANSI mode was successfully enabled
255
+ def enable_ansi!
256
+ return true unless windows? # Already supported on Unix
257
+
258
+ handle = stdout_handle
259
+ return false unless handle
260
+
261
+ current_mode = get_console_mode(handle)
262
+ return false unless current_mode
263
+
264
+ new_mode = current_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING
265
+ result = set_console_mode(new_mode, handle)
266
+
267
+ # Update cached value
268
+ @supports_ansi = result
269
+ result
270
+ end
271
+
272
+ # Disable virtual terminal (ANSI) processing
273
+ # @return [Boolean] True if ANSI mode was successfully disabled
274
+ def disable_ansi!
275
+ return false unless windows?
276
+
277
+ handle = stdout_handle
278
+ return false unless handle
279
+
280
+ current_mode = get_console_mode(handle)
281
+ return false unless current_mode
282
+
283
+ new_mode = current_mode & ~ENABLE_VIRTUAL_TERMINAL_PROCESSING
284
+ result = set_console_mode(new_mode, handle)
285
+
286
+ @supports_ansi = !result if result
287
+ result
288
+ end
289
+
290
+ # Get console screen buffer info
291
+ # @param handle [Integer] Console handle (defaults to stdout)
292
+ # @return [Hash, nil] Screen buffer info or nil on failure
293
+ def get_screen_buffer_info(handle = stdout_handle)
294
+ return nil unless windows? && handle
295
+
296
+ buffer = Fiddle::Pointer.malloc(CONSOLE_SCREEN_BUFFER_INFO_SIZE, Fiddle::RUBY_FREE)
297
+ result = GetConsoleScreenBufferInfo(handle, buffer)
298
+ return nil if result == 0
299
+
300
+ data = buffer[0, CONSOLE_SCREEN_BUFFER_INFO_SIZE]
301
+
302
+ # Unpack the structure
303
+ values = data.unpack("s2 s2 S s4 s2")
304
+
305
+ {
306
+ size: { width: values[0], height: values[1] },
307
+ cursor_position: { x: values[2], y: values[3] },
308
+ attributes: values[4],
309
+ window: {
310
+ left: values[5],
311
+ top: values[6],
312
+ right: values[7],
313
+ bottom: values[8]
314
+ },
315
+ max_window_size: { width: values[9], height: values[10] }
316
+ }
317
+ end
318
+
319
+ # Get the console window dimensions
320
+ # @return [Array<Integer>, nil] [width, height] or nil on failure
321
+ def get_size
322
+ return nil unless windows?
323
+
324
+ info = get_screen_buffer_info
325
+ return nil unless info
326
+
327
+ width = info[:window][:right] - info[:window][:left] + 1
328
+ height = info[:window][:bottom] - info[:window][:top] + 1
329
+
330
+ [width, height]
331
+ end
332
+
333
+ # Get current cursor position
334
+ # @return [Array<Integer>, nil] [x, y] or nil on failure
335
+ def get_cursor_position
336
+ return nil unless windows?
337
+
338
+ info = get_screen_buffer_info
339
+ return nil unless info
340
+
341
+ [info[:cursor_position][:x], info[:cursor_position][:y]]
342
+ end
343
+
344
+ # Set cursor position
345
+ # @param x [Integer] Column (0-indexed)
346
+ # @param y [Integer] Row (0-indexed)
347
+ # @param handle [Integer] Console handle (defaults to stdout)
348
+ # @return [Boolean] Success status
349
+ def set_cursor_position(x, y, handle = stdout_handle)
350
+ return false unless windows? && handle
351
+
352
+ # Pack COORD structure as DWORD (low word = X, high word = Y); mask
353
+ # both fields so a stray high bit can't corrupt the other coordinate.
354
+ coord = ((y & 0xFFFF) << 16) | (x & 0xFFFF)
355
+ SetConsoleCursorPosition(handle, coord) != 0
356
+ end
357
+
358
+ # Set console text attributes (foreground/background colors)
359
+ # @param attributes [Integer] Attribute flags
360
+ # @param handle [Integer] Console handle (defaults to stdout)
361
+ # @return [Boolean] Success status
362
+ def set_text_attribute(attributes, handle = stdout_handle)
363
+ return false unless windows? && handle
364
+
365
+ SetConsoleTextAttribute(handle, attributes) != 0
366
+ end
367
+
368
+ # Get current text attributes
369
+ # @return [Integer, nil] Current attributes or nil on failure
370
+ def get_text_attributes
371
+ return nil unless windows?
372
+
373
+ info = get_screen_buffer_info
374
+ return nil unless info
375
+
376
+ info[:attributes]
377
+ end
378
+
379
+ # Fill console output with a character
380
+ # @param char [String] Character to fill with
381
+ # @param length [Integer] Number of cells to fill
382
+ # @param x [Integer] Starting column
383
+ # @param y [Integer] Starting row
384
+ # @param handle [Integer] Console handle (defaults to stdout)
385
+ # @return [Integer, nil] Number of characters written or nil on failure
386
+ def fill_output_character(char, length, x, y, handle = stdout_handle)
387
+ return nil unless windows? && handle
388
+
389
+ # COORD is two SHORTs marshalled as a packed DWORD; mask both fields.
390
+ coord = ((y & 0xFFFF) << 16) | (x & 0xFFFF)
391
+ written_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
392
+
393
+ # cCharacter is a single UTF-16 code unit. Characters above U+FFFF
394
+ # cannot be represented; mask to the low 16 bits to make the truncation
395
+ # explicit rather than relying on Fiddle's silent narrowing.
396
+ char_code = char.ord & 0xFFFF
397
+ result = FillConsoleOutputCharacterW(handle, char_code, length, coord, written_ptr)
398
+ return nil if result == 0
399
+
400
+ written_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
401
+ end
402
+
403
+ # Fill console output with an attribute
404
+ # @param attribute [Integer] Attribute to fill with
405
+ # @param length [Integer] Number of cells to fill
406
+ # @param x [Integer] Starting column
407
+ # @param y [Integer] Starting row
408
+ # @param handle [Integer] Console handle (defaults to stdout)
409
+ # @return [Integer, nil] Number of cells written or nil on failure
410
+ def fill_output_attribute(attribute, length, x, y, handle = stdout_handle)
411
+ return nil unless windows? && handle
412
+
413
+ coord = ((y & 0xFFFF) << 16) | (x & 0xFFFF)
414
+ written_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
415
+
416
+ result = FillConsoleOutputAttribute(handle, attribute, length, coord, written_ptr)
417
+ return nil if result == 0
418
+
419
+ written_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
420
+ end
421
+
422
+ # Set console window title
423
+ # @param title [String] New window title
424
+ # @return [Boolean] Success status
425
+ def set_title(title)
426
+ return false unless windows?
427
+
428
+ # Convert to UTF-16LE with null terminator
429
+ wide_title = (title + "\0").encode("UTF-16LE")
430
+ SetConsoleTitleW(Fiddle::Pointer[wide_title]) != 0
431
+ end
432
+
433
+ # Show the cursor
434
+ # @param handle [Integer] Console handle (defaults to stdout)
435
+ # @return [Boolean] Success status
436
+ def show_cursor(handle = stdout_handle)
437
+ set_cursor_visibility(true, handle)
438
+ end
439
+
440
+ # Hide the cursor
441
+ # @param handle [Integer] Console handle (defaults to stdout)
442
+ # @return [Boolean] Success status
443
+ def hide_cursor(handle = stdout_handle)
444
+ set_cursor_visibility(false, handle)
445
+ end
446
+
447
+ # Set cursor visibility
448
+ # @param visible [Boolean] Whether cursor should be visible
449
+ # @param handle [Integer] Console handle (defaults to stdout)
450
+ # @return [Boolean] Success status
451
+ def set_cursor_visibility(visible, handle = stdout_handle)
452
+ return false unless windows? && handle
453
+
454
+ # Get current cursor info
455
+ buffer = Fiddle::Pointer.malloc(CONSOLE_CURSOR_INFO_SIZE, Fiddle::RUBY_FREE)
456
+ result = GetConsoleCursorInfo(handle, buffer)
457
+ return false if result == 0
458
+
459
+ # Modify visibility
460
+ data = buffer[0, CONSOLE_CURSOR_INFO_SIZE].unpack("L L")
461
+ cursor_size = data[0]
462
+ buffer[0, CONSOLE_CURSOR_INFO_SIZE] = [cursor_size, visible ? 1 : 0].pack("L L")
463
+
464
+ SetConsoleCursorInfo(handle, buffer) != 0
465
+ end
466
+
467
+ # Write text to console (bypassing Ruby's IO buffering)
468
+ # @param text [String] Text to write
469
+ # @param handle [Integer] Console handle (defaults to stdout)
470
+ # @return [Integer, nil] Number of characters written or nil on failure
471
+ def write_console(text, handle = stdout_handle)
472
+ return nil unless windows? && handle
473
+
474
+ wide_text = text.encode("UTF-16LE")
475
+ # WriteConsoleW counts UTF-16 code UNITS, not Ruby characters (code
476
+ # points). Characters outside the BMP (e.g. emoji) encode as 2 units, so
477
+ # using text.length would truncate the tail. Derive the count from the
478
+ # encoded buffer.
479
+ char_count = wide_text.bytesize / 2
480
+ written_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG, Fiddle::RUBY_FREE)
481
+
482
+ result = WriteConsoleW(handle, Fiddle::Pointer[wide_text], char_count, written_ptr, nil)
483
+ return nil if result == 0
484
+
485
+ written_ptr[0, Fiddle::SIZEOF_LONG].unpack1("L")
486
+ end
487
+
488
+ # Clear the entire screen
489
+ # @param handle [Integer] Console handle (defaults to stdout)
490
+ # @return [Boolean] Success status
491
+ def clear_screen(handle = stdout_handle)
492
+ return false unless windows? && handle
493
+
494
+ info = get_screen_buffer_info(handle)
495
+ return false unless info
496
+
497
+ size = info[:size][:width] * info[:size][:height]
498
+ attributes = info[:attributes]
499
+
500
+ fill_output_character(" ", size, 0, 0, handle)
501
+ fill_output_attribute(attributes, size, 0, 0, handle)
502
+ set_cursor_position(0, 0, handle)
503
+
504
+ true
505
+ end
506
+
507
+ # Erase from cursor to end of line
508
+ # @param handle [Integer] Console handle (defaults to stdout)
509
+ # @return [Boolean] Success status
510
+ def erase_line(handle = stdout_handle)
511
+ return false unless windows? && handle
512
+
513
+ info = get_screen_buffer_info(handle)
514
+ return false unless info
515
+
516
+ x = info[:cursor_position][:x]
517
+ y = info[:cursor_position][:y]
518
+ length = info[:size][:width] - x
519
+ attributes = info[:attributes]
520
+
521
+ fill_output_character(" ", length, x, y, handle)
522
+ fill_output_attribute(attributes, length, x, y, handle)
523
+
524
+ true
525
+ end
526
+
527
+ # Move cursor up
528
+ # @param lines [Integer] Number of lines to move up
529
+ # @param handle [Integer] Console handle (defaults to stdout)
530
+ # @return [Boolean] Success status
531
+ def cursor_up(lines = 1, handle = stdout_handle)
532
+ return false unless windows?
533
+
534
+ pos = get_cursor_position
535
+ return false unless pos
536
+
537
+ new_y = [pos[1] - lines, 0].max
538
+ set_cursor_position(pos[0], new_y, handle)
539
+ end
540
+
541
+ # Move cursor down
542
+ # @param lines [Integer] Number of lines to move down
543
+ # @param handle [Integer] Console handle (defaults to stdout)
544
+ # @return [Boolean] Success status
545
+ def cursor_down(lines = 1, handle = stdout_handle)
546
+ return false unless windows?
547
+
548
+ info = get_screen_buffer_info(handle)
549
+ return false unless info
550
+
551
+ pos = get_cursor_position
552
+ return false unless pos
553
+
554
+ max_y = info[:size][:height] - 1
555
+ new_y = [pos[1] + lines, max_y].min
556
+ set_cursor_position(pos[0], new_y, handle)
557
+ end
558
+
559
+ # Move cursor forward (right)
560
+ # @param columns [Integer] Number of columns to move
561
+ # @param handle [Integer] Console handle (defaults to stdout)
562
+ # @return [Boolean] Success status
563
+ def cursor_forward(columns = 1, handle = stdout_handle)
564
+ return false unless windows?
565
+
566
+ info = get_screen_buffer_info(handle)
567
+ return false unless info
568
+
569
+ pos = get_cursor_position
570
+ return false unless pos
571
+
572
+ max_x = info[:size][:width] - 1
573
+ new_x = [pos[0] + columns, max_x].min
574
+ set_cursor_position(new_x, pos[1], handle)
575
+ end
576
+
577
+ # Move cursor backward (left)
578
+ # @param columns [Integer] Number of columns to move
579
+ # @param handle [Integer] Console handle (defaults to stdout)
580
+ # @return [Boolean] Success status
581
+ def cursor_backward(columns = 1, handle = stdout_handle)
582
+ return false unless windows?
583
+
584
+ pos = get_cursor_position
585
+ return false unless pos
586
+
587
+ new_x = [pos[0] - columns, 0].max
588
+ set_cursor_position(new_x, pos[1], handle)
589
+ end
590
+
591
+ # Move cursor to the beginning of the line
592
+ # @param handle [Integer] Console handle (defaults to stdout)
593
+ # @return [Boolean] Success status
594
+ def cursor_to_column(column = 0, handle = stdout_handle)
595
+ return false unless windows?
596
+
597
+ pos = get_cursor_position
598
+ return false unless pos
599
+
600
+ set_cursor_position(column, pos[1], handle)
601
+ end
602
+
603
+ # Convert ANSI color number to Windows console attributes
604
+ # @param foreground [Integer, nil] ANSI foreground color (0-15)
605
+ # @param background [Integer, nil] ANSI background color (0-15)
606
+ # @return [Integer] Windows console attribute value
607
+ def ansi_to_windows_attributes(foreground: nil, background: nil)
608
+ attributes = 0
609
+ attributes |= ANSI_TO_WINDOWS_FG[foreground] if foreground && foreground < 16
610
+ attributes |= ANSI_TO_WINDOWS_BG[background] if background && background < 16
611
+ attributes
612
+ end
613
+ end
614
+
615
+ # NOTE: ANSI is enabled lazily by Console#initialize (and by the global
616
+ # Rich console on first use), NOT at require time. Mutating the real
617
+ # console's VT mode merely because the gem was loaded is a surprising,
618
+ # unrestored global side effect, so it is intentionally not done here.
619
+ end
620
+ end