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.
data/lib/rich/console.rb CHANGED
@@ -1,549 +1,604 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "color"
4
- require_relative "style"
5
- require_relative "segment"
6
- require_relative "control"
7
- require_relative "cells"
8
- require_relative "terminal_theme"
9
- require_relative "win32_console" if Gem.win_platform?
10
-
11
- module Rich
12
- # Console rendering options
13
- class ConsoleOptions
14
- # @return [Integer] Minimum width for rendering
15
- attr_reader :min_width
16
-
17
- # @return [Integer] Maximum width for rendering
18
- attr_reader :max_width
19
-
20
- # @return [Integer, nil] Height for rendering
21
- attr_reader :height
22
-
23
- # @return [Boolean] Legacy Windows console mode
24
- attr_reader :legacy_windows
25
-
26
- # @return [String] Output encoding
27
- attr_reader :encoding
28
-
29
- # @return [Boolean] Terminal output
30
- attr_reader :is_terminal
31
-
32
- # @return [Boolean] Enable highlighting
33
- attr_reader :highlight
34
-
35
- # @return [Boolean] Enable markup
36
- attr_reader :markup
37
-
38
- # @return [Boolean] No wrapping
39
- attr_reader :no_wrap
40
-
41
- def initialize(
42
- min_width: 1,
43
- max_width: 80,
44
- height: nil,
45
- legacy_windows: false,
46
- encoding: "utf-8",
47
- is_terminal: true,
48
- highlight: true,
49
- markup: true,
50
- no_wrap: false
51
- )
52
- @min_width = min_width
53
- @max_width = max_width
54
- @height = height
55
- @legacy_windows = legacy_windows
56
- @encoding = encoding
57
- @is_terminal = is_terminal
58
- @highlight = highlight
59
- @markup = markup
60
- @no_wrap = no_wrap
61
- freeze
62
- end
63
-
64
- # Update options, returning a new instance
65
- # @return [ConsoleOptions]
66
- def update(**kwargs)
67
- ConsoleOptions.new(
68
- min_width: kwargs.fetch(:min_width, @min_width),
69
- max_width: kwargs.fetch(:max_width, @max_width),
70
- height: kwargs.fetch(:height, @height),
71
- legacy_windows: kwargs.fetch(:legacy_windows, @legacy_windows),
72
- encoding: kwargs.fetch(:encoding, @encoding),
73
- is_terminal: kwargs.fetch(:is_terminal, @is_terminal),
74
- highlight: kwargs.fetch(:highlight, @highlight),
75
- markup: kwargs.fetch(:markup, @markup),
76
- no_wrap: kwargs.fetch(:no_wrap, @no_wrap)
77
- )
78
- end
79
-
80
- # Update width
81
- # @param width [Integer] New width
82
- # @return [ConsoleOptions]
83
- def update_width(width)
84
- update(min_width: width, max_width: width)
85
- end
86
- end
87
-
88
- # Main console class for terminal output
89
- class Console
90
- # @return [IO] Output file
91
- attr_reader :file
92
-
93
- # @return [Symbol] Color system
94
- attr_reader :color_system
95
-
96
- # @return [Boolean] Force terminal mode
97
- attr_reader :force_terminal
98
-
99
- # @return [Boolean] Enable markup
100
- attr_reader :markup
101
-
102
- # @return [Boolean] Enable highlighting
103
- attr_reader :highlight
104
-
105
- # @return [Integer, nil] Override width
106
- attr_reader :width_override
107
-
108
- # @return [Integer, nil] Override height
109
- attr_reader :height_override
110
-
111
- # @return [Style, nil] Default style
112
- attr_reader :style
113
-
114
- # @return [Boolean] Safe output (escape HTML)
115
- attr_reader :safe_box
116
-
117
- # @return [TerminalTheme] Terminal theme
118
- attr_reader :theme
119
-
120
- # Default refresh rate for progress/live (Hz)
121
- DEFAULT_REFRESH_RATE = Gem.win_platform? ? 5 : 10
122
-
123
- def initialize(
124
- file: $stdout,
125
- color_system: nil,
126
- force_terminal: nil,
127
- markup: true,
128
- highlight: true,
129
- width: nil,
130
- height: nil,
131
- style: nil,
132
- safe_box: true,
133
- theme: nil
134
- )
135
- @file = file
136
- @force_terminal = force_terminal
137
- @markup = markup
138
- @highlight = highlight
139
- @width_override = width
140
- @height_override = height
141
- @style = style.is_a?(String) ? Style.parse(style) : style
142
- @safe_box = safe_box
143
- @theme = theme || DEFAULT_TERMINAL_THEME
144
-
145
- @color_system = color_system || detect_color_system
146
- @legacy_windows = detect_legacy_windows
147
-
148
- # Enable ANSI on Windows if possible
149
- if Gem.win_platform? && defined?(Win32Console)
150
- Win32Console.enable_ansi!
151
- end
152
- end
153
-
154
- # @return [Boolean] Whether output is a terminal
155
- def is_terminal?
156
- return @force_terminal unless @force_terminal.nil?
157
- @file.respond_to?(:tty?) && @file.tty?
158
- end
159
-
160
- # @return [Boolean] Is output a terminal
161
- def terminal?
162
- return @force_terminal unless @force_terminal.nil?
163
- return false unless @file.respond_to?(:tty?)
164
-
165
- @file.tty?
166
- end
167
-
168
- # @return [Boolean] Is this a legacy Windows console
169
- def legacy_windows?
170
- @legacy_windows
171
- end
172
-
173
- # @return [Integer] Console width in characters
174
- def width
175
- return @width_override if @width_override
176
-
177
- detect_size[0]
178
- end
179
-
180
- # @return [Integer] Console height in characters
181
- def height
182
- return @height_override if @height_override
183
-
184
- detect_size[1]
185
- end
186
-
187
- # @return [Array<Integer>] [width, height]
188
- def size
189
- [width, height]
190
- end
191
-
192
- # Get console options for rendering
193
- # @return [ConsoleOptions]
194
- def options
195
- ConsoleOptions.new(
196
- min_width: 1,
197
- max_width: width,
198
- height: height,
199
- legacy_windows: @legacy_windows,
200
- encoding: encoding,
201
- is_terminal: terminal?,
202
- highlight: @highlight,
203
- markup: @markup
204
- )
205
- end
206
-
207
- # @return [String] Output encoding
208
- def encoding
209
- @file.respond_to?(:encoding) ? @file.encoding.to_s : "utf-8"
210
- end
211
-
212
- # Print objects to the console
213
- # @param objects [Array] Objects to print
214
- # @param sep [String] Separator
215
- # @param end_str [String] End string
216
- # @param style [String, Style, nil] Style
217
- # @param highlight [Boolean] Enable highlighting
218
- # @return [void]
219
- def print(*objects, sep: " ", end_str: "\n", style: nil, highlight: nil)
220
- highlight = @highlight if highlight.nil?
221
-
222
- text = objects.map do |obj|
223
- render_object(obj, highlight: highlight)
224
- end.join(sep)
225
-
226
- text += end_str
227
-
228
- if style
229
- applied_style = style.is_a?(String) ? Style.parse(style) : style
230
- write_styled(text, applied_style)
231
- else
232
- write(text)
233
- end
234
- end
235
-
236
- # Print with markup parsing
237
- # @param text [String] Text with markup
238
- # @return [void]
239
- def print_markup(text)
240
- segments = parse_markup(text)
241
- write_segments(segments)
242
- end
243
-
244
- # Print JSON with highlighting
245
- # @param json [String, nil] JSON string
246
- # @param data [Object] Data to convert
247
- # @param indent [Integer] Indentation
248
- # @return [void]
249
- def print_json(json = nil, data: nil, indent: 2)
250
- require "json"
251
-
252
- json_str = json || JSON.pretty_generate(data, indent: " " * indent)
253
-
254
- if @highlight
255
- # Colorize JSON
256
- highlighted = colorize_json(json_str)
257
- print(highlighted)
258
- else
259
- print(json_str)
260
- end
261
- end
262
-
263
- # Print a horizontal rule
264
- # @param title [String] Title
265
- # @param style [String] Style
266
- # @return [void]
267
- def rule(title = "", style: "rule.line")
268
- console_width = width
269
- rule_style = Style.parse(style)
270
-
271
- if title.empty?
272
- line = "─" * console_width
273
- write_styled(line + "\n", rule_style)
274
- else
275
- title_length = Cells.cell_len(title) + 2
276
- remaining = console_width - title_length
277
-
278
- if remaining > 0
279
- left_width = remaining / 2
280
- right_width = remaining - left_width
281
- line = "─" * left_width + " #{title} " + "─" * right_width
282
- else
283
- line = title
284
- end
285
-
286
- write_styled(line + "\n", rule_style)
287
- end
288
- end
289
-
290
- # Clear the screen
291
- # @return [void]
292
- def clear
293
- if @legacy_windows && defined?(Win32Console)
294
- Win32Console.clear_screen
295
- else
296
- write(Control.clear_screen)
297
- end
298
- end
299
-
300
- # Show the cursor
301
- # @return [void]
302
- def show_cursor
303
- if @legacy_windows && defined?(Win32Console)
304
- Win32Console.show_cursor
305
- else
306
- write(Control.show_cursor)
307
- end
308
- end
309
-
310
- # Hide the cursor
311
- # @return [void]
312
- def hide_cursor
313
- if @legacy_windows && defined?(Win32Console)
314
- Win32Console.hide_cursor
315
- else
316
- write(Control.hide_cursor)
317
- end
318
- end
319
-
320
- # Set window title
321
- # @param title [String] Window title
322
- # @return [void]
323
- def set_title(title)
324
- if @legacy_windows && defined?(Win32Console)
325
- Win32Console.set_title(title)
326
- else
327
- write(Control.set_title(title))
328
- end
329
- end
330
-
331
- # Write raw text
332
- # @param text [String] Text to write
333
- # @return [void]
334
- def write(text)
335
- @file.write(text)
336
- @file.flush if @file.respond_to?(:flush)
337
- end
338
-
339
- # Write styled text
340
- # @param text [String] Text to write
341
- # @param style [Style] Style to apply
342
- # @return [void]
343
- def write_styled(text, style)
344
- if @legacy_windows && defined?(Win32Console)
345
- write_styled_legacy(text, style)
346
- else
347
- rendered = style.render(color_system: @color_system)
348
- write("#{rendered}#{text}\e[0m")
349
- end
350
- end
351
-
352
- # Write segments to output
353
- # @param segments [Array<Segment>] Segments to write
354
- # @return [void]
355
- def write_segments(segments)
356
- rendered = Segment.render(segments, color_system: @color_system)
357
- write(rendered)
358
- end
359
-
360
- # Inspect an object
361
- # @param obj [Object] Object to inspect
362
- # @param title [String, nil] Title
363
- # @param methods [Boolean] Show methods
364
- # @param docs [Boolean] Show docs
365
- # @return [void]
366
- def inspect(obj, title: nil, methods: false, docs: true)
367
- title ||= obj.class.name
368
-
369
- rule(title, style: "bold")
370
-
371
- print("Class: #{obj.class}")
372
- print("Object ID: #{obj.object_id}")
373
-
374
- if obj.respond_to?(:instance_variables)
375
- ivars = obj.instance_variables
376
- unless ivars.empty?
377
- print("\nInstance Variables:")
378
- ivars.each do |ivar|
379
- value = obj.instance_variable_get(ivar)
380
- print(" #{ivar}: #{value.inspect}")
381
- end
382
- end
383
- end
384
-
385
- if methods && obj.respond_to?(:methods)
386
- obj_methods = (obj.methods - Object.methods).sort
387
- unless obj_methods.empty?
388
- print("\nMethods:")
389
- obj_methods.each do |method|
390
- print(" #{method}")
391
- end
392
- end
393
- end
394
-
395
- rule(style: "bold")
396
- end
397
-
398
- private
399
-
400
- def detect_color_system
401
- return ColorSystem::WINDOWS if Gem.win_platform? && !ansi_supported?
402
-
403
- # Check terminal capabilities
404
- term = ENV["TERM"] || ""
405
- colorterm = ENV["COLORTERM"] || ""
406
-
407
- if colorterm.downcase.include?("truecolor") || colorterm.downcase.include?("24bit")
408
- return ColorSystem::TRUECOLOR
409
- end
410
-
411
- if term.include?("256color") || ENV["TERM_PROGRAM"] == "iTerm.app"
412
- return ColorSystem::EIGHT_BIT
413
- end
414
-
415
- if %w[xterm vt100 screen].any? { |t| term.include?(t) }
416
- return ColorSystem::STANDARD
417
- end
418
-
419
- # Default to truecolor for modern terminals
420
- ColorSystem::TRUECOLOR
421
- end
422
-
423
- def detect_legacy_windows
424
- return false unless Gem.win_platform?
425
- return false if ansi_supported?
426
-
427
- true
428
- end
429
-
430
- def ansi_supported?
431
- return true unless Gem.win_platform?
432
- return true unless defined?(Win32Console)
433
-
434
- Win32Console.supports_ansi?
435
- end
436
-
437
- def detect_size
438
- # Try Ruby's built-in IO#winsize
439
- if @file.respond_to?(:winsize)
440
- begin
441
- height, width = @file.winsize
442
- return [width, height] if width > 0 && height > 0
443
- rescue StandardError
444
- # Fall through
445
- end
446
- end
447
-
448
- # Try Windows API
449
- if Gem.win_platform? && defined?(Win32Console)
450
- size = Win32Console.get_size
451
- return size if size
452
- end
453
-
454
- # Try environment variables
455
- cols = ENV["COLUMNS"]&.to_i
456
- rows = ENV["LINES"]&.to_i
457
- return [cols, rows] if cols && cols > 0 && rows && rows > 0
458
-
459
- # Default
460
- [80, 24]
461
- end
462
-
463
- def render_object(obj, highlight: true)
464
- case obj
465
- when String
466
- if @markup
467
- render_markup(obj)
468
- else
469
- obj
470
- end
471
- when Segment
472
- obj.text
473
- else
474
- obj.to_s
475
- end
476
- end
477
-
478
- def render_markup(text)
479
- # Simple markup rendering - just remove tags for now
480
- # Full implementation in markup.rb
481
- text.gsub(/\[\/?\w+[^\]]*\]/, "")
482
- end
483
-
484
- def parse_markup(text)
485
- # Simple markup parser - converts [style]text[/style] to segments
486
- segments = []
487
- style_stack = []
488
- pos = 0
489
-
490
- tag_regex = /\[(?<close>\/)?(?<style>[^\]]+)\]/
491
-
492
- text.scan(tag_regex) do
493
- match = Regexp.last_match
494
- match_start = match.begin(0)
495
-
496
- # Add text before tag
497
- if match_start > pos
498
- current_style = style_stack.empty? ? nil : style_stack.reduce { |a, b| a + b }
499
- segments << Segment.new(text[pos...match_start], style: current_style)
500
- end
501
-
502
- if match[:close]
503
- style_stack.pop
504
- else
505
- parsed_style = Style.parse(match[:style])
506
- style_stack.push(parsed_style)
507
- end
508
-
509
- pos = match.end(0)
510
- end
511
-
512
- # Add remaining text
513
- if pos < text.length
514
- current_style = style_stack.empty? ? nil : style_stack.reduce { |a, b| a + b }
515
- segments << Segment.new(text[pos..], style: current_style)
516
- end
517
-
518
- segments
519
- end
520
-
521
- def colorize_json(json_str)
522
- # Simple JSON colorization
523
- json_str
524
- .gsub(/"([^"]+)"(?=\s*:)/) { "[cyan]\"#{Regexp.last_match(1)}\"[/]" } # Keys
525
- .gsub(/:\s*"([^"]*)"/) { ": [green]\"#{Regexp.last_match(1)}\"[/]" } # String values
526
- .gsub(/:\s*(\d+\.?\d*)/) { ": [yellow]#{Regexp.last_match(1)}[/]" } # Numbers
527
- .gsub(/:\s*(true|false)/) { ": [italic]#{Regexp.last_match(1)}[/]" } # Booleans
528
- .gsub(/:\s*null/) { ": [dim]null[/]" } # Null
529
- end
530
-
531
- def write_styled_legacy(text, style)
532
- return write(text) unless defined?(Win32Console)
533
-
534
- # Map style to Windows console attributes
535
- fg = style.color&.number || 7
536
- bg = style.bgcolor&.number || 0
537
-
538
- # Apply bold as bright
539
- fg |= 8 if style.bold?
540
-
541
- attributes = Win32Console.ansi_to_windows_attributes(foreground: fg, background: bg)
542
- original_attrs = Win32Console.get_text_attributes
543
-
544
- Win32Console.set_text_attribute(attributes)
545
- write(text)
546
- Win32Console.set_text_attribute(original_attrs) if original_attrs
547
- end
548
- end
549
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "color"
4
+ require_relative "style"
5
+ require_relative "segment"
6
+ require_relative "control"
7
+ require_relative "cells"
8
+ require_relative "text"
9
+ require_relative "markup"
10
+ require_relative "terminal_theme"
11
+ require_relative "win32_console" if Gem.win_platform?
12
+
13
+ module Rich
14
+ # Console rendering options
15
+ class ConsoleOptions
16
+ # @return [Integer] Minimum width for rendering
17
+ attr_reader :min_width
18
+
19
+ # @return [Integer] Maximum width for rendering
20
+ attr_reader :max_width
21
+
22
+ # @return [Integer, nil] Height for rendering
23
+ attr_reader :height
24
+
25
+ # @return [Boolean] Legacy Windows console mode
26
+ attr_reader :legacy_windows
27
+
28
+ # @return [String] Output encoding
29
+ attr_reader :encoding
30
+
31
+ # @return [Boolean] Terminal output
32
+ attr_reader :is_terminal
33
+
34
+ # @return [Boolean] Enable highlighting
35
+ attr_reader :highlight
36
+
37
+ # @return [Boolean] Enable markup
38
+ attr_reader :markup
39
+
40
+ # @return [Boolean] No wrapping
41
+ attr_reader :no_wrap
42
+
43
+ def initialize(
44
+ min_width: 1,
45
+ max_width: 80,
46
+ height: nil,
47
+ legacy_windows: false,
48
+ encoding: "utf-8",
49
+ is_terminal: true,
50
+ highlight: true,
51
+ markup: true,
52
+ no_wrap: false
53
+ )
54
+ @min_width = min_width
55
+ @max_width = max_width
56
+ @height = height
57
+ @legacy_windows = legacy_windows
58
+ @encoding = encoding
59
+ @is_terminal = is_terminal
60
+ @highlight = highlight
61
+ @markup = markup
62
+ @no_wrap = no_wrap
63
+ freeze
64
+ end
65
+
66
+ # Update options, returning a new instance
67
+ # @return [ConsoleOptions]
68
+ def update(**kwargs)
69
+ ConsoleOptions.new(
70
+ min_width: kwargs.fetch(:min_width, @min_width),
71
+ max_width: kwargs.fetch(:max_width, @max_width),
72
+ height: kwargs.fetch(:height, @height),
73
+ legacy_windows: kwargs.fetch(:legacy_windows, @legacy_windows),
74
+ encoding: kwargs.fetch(:encoding, @encoding),
75
+ is_terminal: kwargs.fetch(:is_terminal, @is_terminal),
76
+ highlight: kwargs.fetch(:highlight, @highlight),
77
+ markup: kwargs.fetch(:markup, @markup),
78
+ no_wrap: kwargs.fetch(:no_wrap, @no_wrap)
79
+ )
80
+ end
81
+
82
+ # Update width
83
+ # @param width [Integer] New width
84
+ # @return [ConsoleOptions]
85
+ def update_width(width)
86
+ update(min_width: width, max_width: width)
87
+ end
88
+ end
89
+
90
+ # Main console class for terminal output
91
+ class Console
92
+ # @return [IO] Output file
93
+ attr_reader :file
94
+
95
+ # @return [Symbol] Color system
96
+ attr_reader :color_system
97
+
98
+ # @return [Boolean] Force terminal mode
99
+ attr_reader :force_terminal
100
+
101
+ # @return [Boolean] Enable markup
102
+ attr_reader :markup
103
+
104
+ # @return [Boolean] Enable highlighting
105
+ attr_reader :highlight
106
+
107
+ # @return [Integer, nil] Override width
108
+ attr_reader :width_override
109
+
110
+ # @return [Integer, nil] Override height
111
+ attr_reader :height_override
112
+
113
+ # @return [Style, nil] Default style
114
+ attr_reader :style
115
+
116
+ # @return [Boolean] Safe output (escape HTML)
117
+ attr_reader :safe_box
118
+
119
+ # @return [TerminalTheme] Terminal theme
120
+ attr_reader :theme
121
+
122
+ # Default refresh rate for progress/live (Hz)
123
+ DEFAULT_REFRESH_RATE = Gem.win_platform? ? 5 : 10
124
+
125
+ def initialize(
126
+ file: $stdout,
127
+ color_system: nil,
128
+ force_terminal: nil,
129
+ markup: true,
130
+ highlight: true,
131
+ width: nil,
132
+ height: nil,
133
+ style: nil,
134
+ safe_box: true,
135
+ theme: nil
136
+ )
137
+ @file = file
138
+ @force_terminal = force_terminal
139
+ @markup = markup
140
+ @highlight = highlight
141
+ @width_override = width
142
+ @height_override = height
143
+ @style = style.is_a?(String) ? Style.parse(style) : style
144
+ @safe_box = safe_box
145
+ @theme = theme || DEFAULT_TERMINAL_THEME
146
+
147
+ @color_system = color_system || detect_color_system
148
+ @legacy_windows = detect_legacy_windows
149
+ @no_color = compute_no_color
150
+
151
+ # Enable ANSI on Windows if possible
152
+ if Gem.win_platform? && defined?(Win32Console)
153
+ Win32Console.enable_ansi!
154
+ end
155
+ end
156
+
157
+ # @return [Boolean] Whether color output is suppressed (NO_COLOR, a non-TTY
158
+ # target, or TERM=dumb, unless overridden by FORCE_COLOR/force_terminal)
159
+ def no_color?
160
+ @no_color
161
+ end
162
+
163
+ # @return [Boolean] Whether output is a terminal
164
+ def is_terminal?
165
+ return @force_terminal unless @force_terminal.nil?
166
+ @file.respond_to?(:tty?) && @file.tty?
167
+ end
168
+
169
+ # @return [Boolean] Is output a terminal
170
+ def terminal?
171
+ return @force_terminal unless @force_terminal.nil?
172
+ return false unless @file.respond_to?(:tty?)
173
+
174
+ @file.tty?
175
+ end
176
+
177
+ # @return [Boolean] Is this a legacy Windows console
178
+ def legacy_windows?
179
+ @legacy_windows
180
+ end
181
+
182
+ # @return [Integer] Console width in characters
183
+ def width
184
+ return @width_override if @width_override
185
+
186
+ detect_size[0]
187
+ end
188
+
189
+ # @return [Integer] Console height in characters
190
+ def height
191
+ return @height_override if @height_override
192
+
193
+ detect_size[1]
194
+ end
195
+
196
+ # @return [Array<Integer>] [width, height]
197
+ def size
198
+ [width, height]
199
+ end
200
+
201
+ # Get console options for rendering
202
+ # @return [ConsoleOptions]
203
+ def options
204
+ ConsoleOptions.new(
205
+ min_width: 1,
206
+ max_width: width,
207
+ height: height,
208
+ legacy_windows: @legacy_windows,
209
+ encoding: encoding,
210
+ is_terminal: terminal?,
211
+ highlight: @highlight,
212
+ markup: @markup
213
+ )
214
+ end
215
+
216
+ # @return [String] Output encoding
217
+ def encoding
218
+ @file.respond_to?(:encoding) ? @file.encoding.to_s : "utf-8"
219
+ end
220
+
221
+ # Print objects to the console
222
+ # @param objects [Array] Objects to print
223
+ # @param sep [String] Separator
224
+ # @param end_str [String] End string
225
+ # @param style [String, Style, nil] Style
226
+ # @param highlight [Boolean] Enable highlighting
227
+ # @return [void]
228
+ def print(*objects, sep: " ", end_str: "\n", style: nil, highlight: nil)
229
+ highlight = @highlight if highlight.nil?
230
+ base_style = style.is_a?(String) ? Style.parse(style) : style
231
+
232
+ segments = []
233
+ objects.each_with_index do |obj, index|
234
+ segments.concat(render_object(obj, highlight: highlight))
235
+ segments << Segment.new(sep) if sep && !sep.empty? && index < objects.length - 1
236
+ end
237
+ segments << Segment.new(end_str) if end_str && !end_str.empty?
238
+
239
+ segments = Segment.apply_style(segments, style: base_style) if base_style
240
+
241
+ write_segments(segments)
242
+ end
243
+
244
+ # Print with markup parsing
245
+ # @param text [String] Text with markup
246
+ # @return [void]
247
+ def print_markup(text)
248
+ write_segments(Markup.parse(text).to_segments)
249
+ end
250
+
251
+ # Print JSON with highlighting
252
+ # @param json [String, nil] JSON string
253
+ # @param data [Object] Data to convert
254
+ # @param indent [Integer] Indentation
255
+ # @return [void]
256
+ def print_json(json = nil, data: nil, indent: 2)
257
+ require "json"
258
+
259
+ json_str = json || ::JSON.pretty_generate(data, indent: " " * indent)
260
+
261
+ if @highlight
262
+ # Colorize JSON
263
+ highlighted = colorize_json(json_str)
264
+ print(highlighted)
265
+ else
266
+ print(json_str)
267
+ end
268
+ end
269
+
270
+ # Print a horizontal rule
271
+ # @param title [String] Title
272
+ # @param style [String] Style
273
+ # @return [void]
274
+ def rule(title = "", style: "rule.line")
275
+ console_width = width
276
+ rule_style = Style.parse(style)
277
+
278
+ if title.empty?
279
+ line = "─" * console_width
280
+ write_styled(line + "\n", rule_style)
281
+ else
282
+ title_length = Cells.cell_len(title) + 2
283
+ remaining = console_width - title_length
284
+
285
+ if remaining > 0
286
+ left_width = remaining / 2
287
+ right_width = remaining - left_width
288
+ line = "─" * left_width + " #{title} " + "─" * right_width
289
+ else
290
+ line = title
291
+ end
292
+
293
+ write_styled(line + "\n", rule_style)
294
+ end
295
+ end
296
+
297
+ # Clear the screen
298
+ # @return [void]
299
+ def clear
300
+ if @legacy_windows && defined?(Win32Console)
301
+ Win32Console.clear_screen
302
+ else
303
+ write(Control.clear_screen)
304
+ end
305
+ end
306
+
307
+ # Show the cursor
308
+ # @return [void]
309
+ def show_cursor
310
+ if @legacy_windows && defined?(Win32Console)
311
+ Win32Console.show_cursor
312
+ else
313
+ write(Control.show_cursor)
314
+ end
315
+ end
316
+
317
+ # Hide the cursor
318
+ # @return [void]
319
+ def hide_cursor
320
+ if @legacy_windows && defined?(Win32Console)
321
+ Win32Console.hide_cursor
322
+ else
323
+ write(Control.hide_cursor)
324
+ end
325
+ end
326
+
327
+ # Set window title
328
+ # @param title [String] Window title
329
+ # @return [void]
330
+ def set_title(title)
331
+ if @legacy_windows && defined?(Win32Console)
332
+ Win32Console.set_title(title)
333
+ else
334
+ write(Control.set_title(title))
335
+ end
336
+ end
337
+
338
+ # Write raw text
339
+ # @param text [String] Text to write
340
+ # @return [void]
341
+ def write(text)
342
+ @file.write(text)
343
+ @file.flush if @file.respond_to?(:flush)
344
+ end
345
+
346
+ # Write styled text
347
+ # @param text [String] Text to write
348
+ # @param style [Style] Style to apply
349
+ # @return [void]
350
+ def write_styled(text, style)
351
+ if @no_color
352
+ write(text)
353
+ elsif @legacy_windows && defined?(Win32Console)
354
+ write_styled_legacy(text, style)
355
+ else
356
+ rendered = style.render(color_system: @color_system)
357
+ write("#{rendered}#{text}\e[0m")
358
+ end
359
+ end
360
+
361
+ # Write segments to output
362
+ # @param segments [Array<Segment>] Segments to write
363
+ # @return [void]
364
+ def write_segments(segments)
365
+ if @no_color
366
+ segments.each { |segment| write(segment.text) unless segment.control? }
367
+ elsif @legacy_windows && defined?(Win32Console)
368
+ # Legacy console cannot interpret ANSI; drive colors via the Console API.
369
+ segments.each do |segment|
370
+ if segment.control?
371
+ segment.control.each { |code| write(Control.generate(*code)) }
372
+ elsif segment.style
373
+ write_styled_legacy(segment.text, segment.style)
374
+ else
375
+ write(segment.text)
376
+ end
377
+ end
378
+ else
379
+ write(Segment.render(segments, color_system: @color_system))
380
+ end
381
+ end
382
+
383
+ # Inspect an object
384
+ # @param obj [Object] Object to inspect
385
+ # @param title [String, nil] Title
386
+ # @param methods [Boolean] Show methods
387
+ # @param docs [Boolean] Show docs
388
+ # @return [void]
389
+ def inspect(obj, title: nil, methods: false, docs: true)
390
+ title ||= obj.class.name
391
+
392
+ rule(title, style: "bold")
393
+
394
+ print("Class: #{obj.class}")
395
+ print("Object ID: #{obj.object_id}")
396
+
397
+ if obj.respond_to?(:instance_variables)
398
+ ivars = obj.instance_variables
399
+ unless ivars.empty?
400
+ print("\nInstance Variables:")
401
+ ivars.each do |ivar|
402
+ value = obj.instance_variable_get(ivar)
403
+ print(" #{ivar}: #{value.inspect}")
404
+ end
405
+ end
406
+ end
407
+
408
+ if methods && obj.respond_to?(:methods)
409
+ obj_methods = (obj.methods - Object.methods).sort
410
+ unless obj_methods.empty?
411
+ print("\nMethods:")
412
+ obj_methods.each do |method|
413
+ print(" #{method}")
414
+ end
415
+ end
416
+ end
417
+
418
+ rule(style: "bold")
419
+ end
420
+
421
+ private
422
+
423
+ # Decide whether to suppress color, honoring the cross-platform
424
+ # conventions (https://no-color.org and FORCE_COLOR) plus TTY detection.
425
+ # Precedence: explicit force_terminal:false > NO_COLOR/TERM=dumb >
426
+ # FORCE_COLOR/force_terminal:true > whether the output is a TTY.
427
+ def compute_no_color
428
+ return true if @force_terminal == false
429
+ return true if ENV.key?("NO_COLOR") && !ENV["NO_COLOR"].to_s.empty?
430
+ return true if (ENV["TERM"] || "").casecmp?("dumb")
431
+
432
+ return false if @force_terminal == true
433
+ return false if ENV["FORCE_COLOR"] && !ENV["FORCE_COLOR"].to_s.empty?
434
+
435
+ !terminal?
436
+ end
437
+
438
+ def detect_color_system
439
+ return ColorSystem::WINDOWS if Gem.win_platform? && !ansi_supported?
440
+
441
+ # Check terminal capabilities
442
+ term = ENV["TERM"] || ""
443
+ colorterm = ENV["COLORTERM"] || ""
444
+
445
+ if colorterm.downcase.include?("truecolor") || colorterm.downcase.include?("24bit")
446
+ return ColorSystem::TRUECOLOR
447
+ end
448
+
449
+ if term.include?("256color") || ENV["TERM_PROGRAM"] == "iTerm.app"
450
+ return ColorSystem::EIGHT_BIT
451
+ end
452
+
453
+ if %w[xterm vt100 screen].any? { |t| term.include?(t) }
454
+ return ColorSystem::STANDARD
455
+ end
456
+
457
+ # Default to truecolor for modern terminals
458
+ ColorSystem::TRUECOLOR
459
+ end
460
+
461
+ def detect_legacy_windows
462
+ return false unless Gem.win_platform?
463
+ return false if ansi_supported?
464
+
465
+ true
466
+ end
467
+
468
+ def ansi_supported?
469
+ return true unless Gem.win_platform?
470
+ return true unless defined?(Win32Console)
471
+
472
+ Win32Console.supports_ansi?
473
+ end
474
+
475
+ def detect_size
476
+ # Try Ruby's built-in IO#winsize
477
+ if @file.respond_to?(:winsize)
478
+ begin
479
+ height, width = @file.winsize
480
+ return [width, height] if width > 0 && height > 0
481
+ rescue StandardError
482
+ # Fall through
483
+ end
484
+ end
485
+
486
+ # Try Windows API
487
+ if Gem.win_platform? && defined?(Win32Console)
488
+ size = Win32Console.get_size
489
+ return size if size
490
+ end
491
+
492
+ # Try environment variables (decoupled: COLUMNS and LINES may be set
493
+ # independently — a common situation on Unix).
494
+ cols = ENV["COLUMNS"]&.to_i
495
+ rows = ENV["LINES"]&.to_i
496
+ if (cols && cols > 0) || (rows && rows > 0)
497
+ return [cols && cols > 0 ? cols : 80, rows && rows > 0 ? rows : 24]
498
+ end
499
+
500
+ # Unix fallback: ask the controlling terminal via stty.
501
+ unless Gem.win_platform?
502
+ size = detect_size_via_stty
503
+ return size if size
504
+ end
505
+
506
+ # Default
507
+ [80, 24]
508
+ end
509
+
510
+ # Query terminal size with `stty size` against the controlling terminal.
511
+ # @return [Array<Integer>, nil] [width, height] or nil if unavailable
512
+ def detect_size_via_stty
513
+ out = `stty size < /dev/tty 2>/dev/null`
514
+ rows, cols = out.split.map(&:to_i)
515
+ return [cols, rows] if cols && cols > 0 && rows && rows > 0
516
+
517
+ nil
518
+ rescue StandardError
519
+ nil
520
+ end
521
+
522
+ # Convert any printable object into an array of Segments.
523
+ # Strings honor markup (when enabled); Rich renderables are rendered via
524
+ # their to_segments/render protocol; everything else falls back to to_s.
525
+ # @return [Array<Segment>]
526
+ def render_object(obj, highlight: true)
527
+ case obj
528
+ when Segment
529
+ [obj]
530
+ when String
531
+ @markup ? Markup.parse(obj).to_segments : [Segment.new(obj)]
532
+ when Text
533
+ obj.to_segments
534
+ else
535
+ if obj.respond_to?(:to_segments)
536
+ if accepts_keyword?(obj, :to_segments, :max_width)
537
+ obj.to_segments(max_width: width)
538
+ else
539
+ obj.to_segments
540
+ end
541
+ elsif obj.respond_to?(:render) && obj.method(:render).owner != Object
542
+ [Segment.new(render_renderable(obj))]
543
+ else
544
+ [Segment.new(obj.to_s)]
545
+ end
546
+ end
547
+ end
548
+
549
+ # Call a renderable's #render passing only the keywords it accepts.
550
+ def render_renderable(obj)
551
+ kwargs = {}
552
+ kwargs[:max_width] = width if accepts_keyword?(obj, :render, :max_width)
553
+ kwargs[:color_system] = @color_system if accepts_keyword?(obj, :render, :color_system)
554
+ kwargs.empty? ? obj.render : obj.render(**kwargs)
555
+ end
556
+
557
+ # @return [Boolean] whether obj#method declares the given keyword parameter
558
+ def accepts_keyword?(obj, method_name, keyword)
559
+ obj.method(method_name).parameters.any? do |type, name|
560
+ (type == :key || type == :keyreq) && name == keyword
561
+ end
562
+ rescue NameError
563
+ false
564
+ end
565
+
566
+ def colorize_json(json_str)
567
+ # Simple JSON colorization
568
+ json_str
569
+ .gsub(/"([^"]+)"(?=\s*:)/) { "[cyan]\"#{Regexp.last_match(1)}\"[/]" } # Keys
570
+ .gsub(/:\s*"([^"]*)"/) { ": [green]\"#{Regexp.last_match(1)}\"[/]" } # String values
571
+ .gsub(/:\s*(\d+\.?\d*)/) { ": [yellow]#{Regexp.last_match(1)}[/]" } # Numbers
572
+ .gsub(/:\s*(true|false)/) { ": [italic]#{Regexp.last_match(1)}[/]" } # Booleans
573
+ .gsub(/:\s*null/) { ": [dim]null[/]" } # Null
574
+ end
575
+
576
+ def write_styled_legacy(text, style)
577
+ return write(text) unless defined?(Win32Console)
578
+
579
+ # Map style to Windows console attributes. Colors must be reduced to a
580
+ # 0-15 index first; an 8-bit/truecolor number (>= 16) would otherwise be
581
+ # dropped to attribute 0 (black on black) and render invisibly.
582
+ fg = legacy_color_index(style.color) || 7
583
+ bg = legacy_color_index(style.bgcolor) || 0
584
+
585
+ # Apply bold as bright
586
+ fg |= 8 if style.bold?
587
+
588
+ attributes = Win32Console.ansi_to_windows_attributes(foreground: fg, background: bg)
589
+ original_attrs = Win32Console.get_text_attributes
590
+
591
+ Win32Console.set_text_attribute(attributes)
592
+ write(text)
593
+ Win32Console.set_text_attribute(original_attrs) if original_attrs
594
+ end
595
+
596
+ # Reduce any color to a 0-15 Windows console index (nil if no color).
597
+ def legacy_color_index(color)
598
+ return nil unless color
599
+
600
+ number = color.downgrade(ColorSystem::WINDOWS).number
601
+ number && number < 16 ? number : nil
602
+ end
603
+ end
604
+ end