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,430 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "style"
4
+ require_relative "segment"
5
+ require_relative "cells"
6
+
7
+ module Rich
8
+ # Progress bar styles
9
+ module ProgressStyle
10
+ # Bar characters
11
+ BAR_FILLED = "━"
12
+ BAR_UNFILLED = "━"
13
+ BAR_START = ""
14
+ BAR_END = ""
15
+
16
+ # Spinner frames
17
+ DOTS = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
18
+ LINE = %w[| / - \\].freeze
19
+ ARROW = %w[←↖↑↗→↘↓↙].freeze
20
+ CIRCLE = %w[◐ ◓ ◑ ◒].freeze
21
+ MOON = %w[🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘].freeze
22
+ BOUNCE = %w[⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈].freeze
23
+ end
24
+
25
+ # A spinning animation indicator
26
+ class Spinner
27
+ # @return [Array<String>] Spinner frames
28
+ attr_reader :frames
29
+
30
+ # @return [Style, nil] Style
31
+ attr_reader :style
32
+
33
+ # @return [Float] Speed (seconds per frame)
34
+ attr_reader :speed
35
+
36
+ def initialize(frames: ProgressStyle::DOTS, style: nil, speed: 0.1)
37
+ @frames = frames
38
+ @style = style.is_a?(String) ? Style.parse(style) : style
39
+ @speed = speed
40
+ @frame_index = 0
41
+ @last_update = Time.now
42
+ end
43
+
44
+ # Get current frame
45
+ # @return [String]
46
+ def frame
47
+ @frames[@frame_index % @frames.length]
48
+ end
49
+
50
+ # Advance to next frame
51
+ # @return [String] Current frame after advance
52
+ def advance
53
+ @frame_index = (@frame_index + 1) % @frames.length
54
+ @last_update = Time.now
55
+ frame
56
+ end
57
+
58
+ # Update if enough time has passed
59
+ # @return [Boolean] True if frame changed
60
+ def update
61
+ if Time.now - @last_update >= @speed
62
+ advance
63
+ true
64
+ else
65
+ false
66
+ end
67
+ end
68
+
69
+ # Get segment for current frame
70
+ # @return [Segment]
71
+ def to_segment
72
+ Segment.new(frame, style: @style)
73
+ end
74
+
75
+ # Reset to first frame
76
+ def reset
77
+ @frame_index = 0
78
+ @last_update = Time.now
79
+ end
80
+ end
81
+
82
+ # A progress bar for tracking task completion
83
+ class ProgressBar
84
+ # @return [Integer] Total steps
85
+ attr_reader :total
86
+
87
+ # @return [Integer] Completed steps
88
+ attr_reader :completed
89
+
90
+ # @return [Integer] Width of the bar
91
+ attr_reader :width
92
+
93
+ # @return [Style, nil] Completed portion style
94
+ attr_reader :complete_style
95
+
96
+ # @return [Style, nil] Remaining portion style
97
+ attr_reader :incomplete_style
98
+
99
+ # @return [Style, nil] Finished style
100
+ attr_reader :finished_style
101
+
102
+ # @return [Boolean] Show percentage
103
+ attr_reader :show_percentage
104
+
105
+ # @return [Boolean] Pulse animation
106
+ attr_reader :pulse
107
+
108
+ # @return [String] Bar character (filled)
109
+ attr_reader :bar_char
110
+
111
+ # @return [String] Bar character (unfilled)
112
+ attr_reader :unfilled_char
113
+
114
+ def initialize(
115
+ total: 100,
116
+ completed: 0,
117
+ width: 40,
118
+ complete_style: "bar.complete",
119
+ incomplete_style: "bar.back",
120
+ finished_style: "bar.finished",
121
+ show_percentage: true,
122
+ pulse: false,
123
+ bar_char: "━",
124
+ unfilled_char: "━"
125
+ )
126
+ @total = [total, 1].max
127
+ @completed = [completed, 0].max
128
+ @width = width
129
+ @complete_style = complete_style.is_a?(String) ? Style.parse(complete_style) : complete_style
130
+ @incomplete_style = incomplete_style.is_a?(String) ? Style.parse(incomplete_style) : incomplete_style
131
+ @finished_style = finished_style.is_a?(String) ? Style.parse(finished_style) : finished_style
132
+ @show_percentage = show_percentage
133
+ @pulse = pulse
134
+ @bar_char = bar_char
135
+ @unfilled_char = unfilled_char
136
+ @start_time = nil
137
+ end
138
+
139
+ # @return [Float] Progress as fraction (0.0 to 1.0)
140
+ def progress
141
+ @completed.to_f / @total
142
+ end
143
+
144
+ # @return [Integer] Progress as percentage (0 to 100)
145
+ def percentage
146
+ (progress * 100).round
147
+ end
148
+
149
+ # @return [Boolean] True if complete
150
+ def finished?
151
+ @completed >= @total
152
+ end
153
+
154
+ # Update progress
155
+ # @param advance [Integer] Steps to advance
156
+ # @return [self]
157
+ def advance(steps = 1)
158
+ @start_time ||= Time.now
159
+ @completed = [@completed + steps, @total].min
160
+ self
161
+ end
162
+
163
+ # Set completed value directly
164
+ # @param value [Integer] Completed steps
165
+ # @return [self]
166
+ def update(value)
167
+ @start_time ||= Time.now
168
+ @completed = [[value, 0].max, @total].min
169
+ self
170
+ end
171
+
172
+ # Reset progress
173
+ # @return [self]
174
+ def reset
175
+ @completed = 0
176
+ @start_time = nil
177
+ self
178
+ end
179
+
180
+ # Elapsed time since start
181
+ # @return [Float, nil] Seconds elapsed
182
+ def elapsed
183
+ return nil unless @start_time
184
+
185
+ Time.now - @start_time
186
+ end
187
+
188
+ # Estimated time remaining
189
+ # @return [Float, nil] Seconds remaining
190
+ def eta
191
+ return nil unless @start_time && progress > 0
192
+
193
+ elapsed_time = elapsed
194
+ total_estimated = elapsed_time / progress
195
+ total_estimated - elapsed_time
196
+ end
197
+
198
+ # Format time as string
199
+ # @param seconds [Float] Seconds
200
+ # @return [String] Formatted time
201
+ def format_time(seconds)
202
+ return "--:--" unless seconds
203
+
204
+ mins = (seconds / 60).floor
205
+ secs = (seconds % 60).floor
206
+
207
+ if mins >= 60
208
+ hours = (mins / 60).floor
209
+ mins = mins % 60
210
+ format("%d:%02d:%02d", hours, mins, secs)
211
+ else
212
+ format("%d:%02d", mins, secs)
213
+ end
214
+ end
215
+
216
+ # Render progress bar to segments
217
+ # @return [Array<Segment>]
218
+ def to_segments
219
+ segments = []
220
+
221
+ filled_width = (progress * @width).round
222
+ unfilled_width = @width - filled_width
223
+
224
+ # Bar
225
+ style = finished? ? @finished_style : @complete_style
226
+
227
+ if filled_width > 0
228
+ segments << Segment.new(@bar_char * filled_width, style: style)
229
+ end
230
+
231
+ if unfilled_width > 0
232
+ segments << Segment.new(@unfilled_char * unfilled_width, style: @incomplete_style)
233
+ end
234
+
235
+ # Percentage
236
+ if @show_percentage
237
+ segments << Segment.new(" #{percentage}%")
238
+ end
239
+
240
+ segments
241
+ end
242
+
243
+ # Render to string with ANSI codes
244
+ # @param color_system [Symbol] Color system
245
+ # @return [String]
246
+ def render(color_system: ColorSystem::TRUECOLOR)
247
+ Segment.render(to_segments, color_system: color_system)
248
+ end
249
+ end
250
+
251
+ # A task in progress tracking
252
+ class ProgressTask
253
+ # @return [String] Task description
254
+ attr_reader :description
255
+
256
+ # @return [Integer] Total steps
257
+ attr_reader :total
258
+
259
+ # @return [Integer] Completed steps
260
+ attr_reader :completed
261
+
262
+ # @return [Boolean] Task is finished
263
+ attr_reader :finished
264
+
265
+ # @return [Time] Start time
266
+ attr_reader :start_time
267
+
268
+ # @return [Time, nil] End time
269
+ attr_reader :end_time
270
+
271
+ def initialize(description:, total: 100)
272
+ @description = description
273
+ @total = total
274
+ @completed = 0
275
+ @finished = false
276
+ @start_time = Time.now
277
+ @end_time = nil
278
+ end
279
+
280
+ # Update progress
281
+ # @param advance [Integer] Steps to advance
282
+ def advance(steps = 1)
283
+ @completed = [@completed + steps, @total].min
284
+ finish if @completed >= @total
285
+ end
286
+
287
+ # Set completed directly
288
+ # @param value [Integer] Completed value
289
+ def update(value)
290
+ @completed = [[value, 0].max, @total].min
291
+ finish if @completed >= @total
292
+ end
293
+
294
+ # Mark as finished
295
+ def finish
296
+ @finished = true
297
+ @end_time = Time.now
298
+ end
299
+
300
+ # @return [Float] Progress fraction
301
+ def progress
302
+ @completed.to_f / @total
303
+ end
304
+
305
+ # @return [Float, nil] Elapsed time
306
+ def elapsed
307
+ (@end_time || Time.now) - @start_time
308
+ end
309
+ end
310
+
311
+ # Progress display for multiple tasks
312
+ class Progress
313
+ # Windows has slower console, so refresh less frequently
314
+ DEFAULT_REFRESH = Gem.win_platform? ? 0.2 : 0.1
315
+
316
+ # @return [Array<ProgressTask>] Tasks
317
+ attr_reader :tasks
318
+
319
+ # @return [Console] Console for output
320
+ attr_reader :console
321
+
322
+ # @return [Float] Refresh interval
323
+ attr_reader :refresh_rate
324
+
325
+ def initialize(console: nil, refresh_rate: DEFAULT_REFRESH, transient: true)
326
+ @console = console || Console.new
327
+ @refresh_rate = refresh_rate
328
+ @transient = transient
329
+ @tasks = []
330
+ @started = false
331
+ @finished = false
332
+ @last_render = nil
333
+ end
334
+
335
+ # Add a new task
336
+ # @param description [String] Task description
337
+ # @param total [Integer] Total steps
338
+ # @return [ProgressTask]
339
+ def add_task(description, total: 100)
340
+ task = ProgressTask.new(description: description, total: total)
341
+ @tasks << task
342
+ task
343
+ end
344
+
345
+ # Start progress display
346
+ # @yield Block to execute with progress tracking
347
+ # @return [void]
348
+ def start
349
+ @started = true
350
+ @console.hide_cursor
351
+
352
+ if block_given?
353
+ begin
354
+ yield self
355
+ ensure
356
+ stop
357
+ end
358
+ end
359
+ end
360
+
361
+ # Stop progress display
362
+ # @return [void]
363
+ def stop
364
+ return unless @started
365
+
366
+ @finished = true
367
+ render_final
368
+ @console.show_cursor
369
+ @started = false
370
+ end
371
+
372
+ # Refresh display if needed
373
+ # @return [void]
374
+ def refresh
375
+ return unless @started
376
+
377
+ now = Time.now
378
+ if @last_render.nil? || now - @last_render >= @refresh_rate
379
+ render
380
+ @last_render = now
381
+ end
382
+ end
383
+
384
+ private
385
+
386
+ def render
387
+ # Clear previous output
388
+ lines = @tasks.length
389
+ @console.write("\e[#{lines}A\e[J") if @last_render
390
+
391
+ @tasks.each do |task|
392
+ render_task(task)
393
+ @console.write("\n")
394
+ end
395
+ end
396
+
397
+ def render_final
398
+ return if @transient
399
+
400
+ @tasks.each do |task|
401
+ render_task(task)
402
+ @console.write("\n")
403
+ end
404
+ end
405
+
406
+ def render_task(task)
407
+ bar = ProgressBar.new(
408
+ total: task.total,
409
+ completed: task.completed,
410
+ width: 30,
411
+ complete_style: "green",
412
+ incomplete_style: "dim"
413
+ )
414
+
415
+ description = task.description.ljust(30)[0, 30]
416
+ percentage = "#{(task.progress * 100).round}%".rjust(4)
417
+ elapsed = format_time(task.elapsed)
418
+
419
+ @console.write("#{description} ")
420
+ @console.write_segments(bar.to_segments)
421
+ @console.write(" #{percentage} • #{elapsed}")
422
+ end
423
+
424
+ def format_time(seconds)
425
+ mins = (seconds / 60).floor
426
+ secs = (seconds % 60).floor
427
+ format("%d:%02d", mins, secs)
428
+ end
429
+ end
430
+ end