rich-ruby 1.0.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,549 @@
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