ruby-progress 1.3.2 → 1.3.4
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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +20 -14
- data/CHANGELOG.md +62 -96
- data/Gemfile +2 -0
- data/Gemfile.lock +7 -1
- data/demo_screencast.rb +130 -122
- data/lib/ruby-progress/cli/fill_options.rb +8 -2
- data/lib/ruby-progress/cli/ripple_cli.rb +6 -1
- data/lib/ruby-progress/cli/ripple_options.rb +4 -0
- data/lib/ruby-progress/cli/twirl_options.rb +4 -0
- data/lib/ruby-progress/cli/twirl_runner.rb +5 -3
- data/lib/ruby-progress/cli/worm_cli.rb +2 -1
- data/lib/ruby-progress/cli/worm_options.rb +4 -0
- data/lib/ruby-progress/cli/worm_runner.rb +9 -4
- data/lib/ruby-progress/fill_cli.rb +4 -2
- data/lib/ruby-progress/output_capture.rb +169 -37
- data/lib/ruby-progress/version.rb +1 -1
- data/lib/ruby-progress/worm.rb +8 -61
- data/screencast +2497 -26
- data/screencast.svg +1 -0
- metadata +30 -1
|
@@ -2,27 +2,69 @@
|
|
|
2
2
|
|
|
3
3
|
require 'pty'
|
|
4
4
|
require 'io/console'
|
|
5
|
-
require '
|
|
5
|
+
require 'english'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
require 'tty-cursor'
|
|
10
|
+
require 'tty-screen'
|
|
11
|
+
rescue LoadError
|
|
12
|
+
# fall back to ANSI sequences when tty gems are not installed
|
|
13
|
+
end
|
|
6
14
|
|
|
7
15
|
module RubyProgress
|
|
8
|
-
#
|
|
9
|
-
# for printing captured output while the animation
|
|
16
|
+
# PTY-based live output capture that reserves a small terminal area
|
|
17
|
+
# for printing captured output while the animation draws elsewhere.
|
|
10
18
|
class OutputCapture
|
|
11
19
|
attr_reader :exit_status
|
|
12
20
|
|
|
13
|
-
def initialize(command:, lines: 3, position: :above, log_path: nil)
|
|
21
|
+
def initialize(command:, lines: 3, position: :above, log_path: nil, stream: false, debug: nil)
|
|
14
22
|
@command = command
|
|
15
|
-
|
|
16
|
-
@
|
|
23
|
+
# Coerce lines into a positive Integer
|
|
24
|
+
@lines = (lines || 3).to_i
|
|
25
|
+
@lines = 1 if @lines < 1
|
|
26
|
+
|
|
27
|
+
# Normalize position (accept :top/:bottom or :above/:below or strings)
|
|
28
|
+
pos = position.respond_to?(:to_sym) ? position.to_sym : position
|
|
29
|
+
@position = case pos
|
|
30
|
+
when :top, 'top' then :above
|
|
31
|
+
when :bottom, 'bottom' then :below
|
|
32
|
+
when :above, 'above' then :above
|
|
33
|
+
when :below, 'below' then :below
|
|
34
|
+
else
|
|
35
|
+
:above
|
|
36
|
+
end
|
|
37
|
+
|
|
17
38
|
@buffer = []
|
|
18
39
|
@buf_mutex = Mutex.new
|
|
19
40
|
@stop = false
|
|
20
41
|
@log_path = log_path
|
|
21
42
|
@log_file = nil
|
|
43
|
+
@stream = stream
|
|
44
|
+
|
|
45
|
+
@debug = if debug.nil?
|
|
46
|
+
ENV.fetch('RUBY_PROGRESS_DEBUG', nil) && ENV['RUBY_PROGRESS_DEBUG'] != '0'
|
|
47
|
+
else
|
|
48
|
+
debug
|
|
49
|
+
end
|
|
50
|
+
@debug_path = '/tmp/ruby-progress-debug.log'
|
|
51
|
+
|
|
52
|
+
if @debug
|
|
53
|
+
begin
|
|
54
|
+
FileUtils.mkdir_p(File.dirname(@debug_path))
|
|
55
|
+
File.open(@debug_path, 'w') { |f| f.puts("debug start: #{Time.now}") }
|
|
56
|
+
rescue StandardError
|
|
57
|
+
@debug = false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Debug: log init if requested via ENV or explicit debug flag
|
|
62
|
+
debug_log("init: position=#{@position.inspect}; lines=#{@lines}")
|
|
22
63
|
end
|
|
23
64
|
|
|
24
|
-
# Start the child process
|
|
65
|
+
# Start capturing the child process. Returns self.
|
|
25
66
|
def start
|
|
67
|
+
reserve_space($stderr) if @stream
|
|
26
68
|
@reader_thread = Thread.new { spawn_and_read }
|
|
27
69
|
self
|
|
28
70
|
end
|
|
@@ -32,45 +74,98 @@ module RubyProgress
|
|
|
32
74
|
@reader_thread&.join
|
|
33
75
|
end
|
|
34
76
|
|
|
35
|
-
# Wait for the reader thread to complete
|
|
36
77
|
def wait
|
|
37
78
|
@reader_thread&.join
|
|
38
79
|
end
|
|
39
80
|
|
|
40
|
-
# Return snapshot of buffered lines (thread-safe)
|
|
41
81
|
def lines
|
|
42
82
|
@buf_mutex.synchronize { @buffer.dup }
|
|
43
83
|
end
|
|
44
84
|
|
|
45
|
-
# Returns whether the reader thread is still alive
|
|
46
85
|
def alive?
|
|
47
86
|
@reader_thread&.alive? || false
|
|
48
87
|
end
|
|
49
88
|
|
|
50
|
-
# Redraw
|
|
51
|
-
# io - IO object to write to (default $stderr)
|
|
89
|
+
# Redraw the reserved area using the current buffered lines.
|
|
52
90
|
def redraw(io = $stderr)
|
|
53
91
|
buf = lines
|
|
54
|
-
|
|
92
|
+
debug_log("redraw called; buffer=#{buf.size}; lines=#{@lines}; position=#{@position}")
|
|
93
|
+
|
|
94
|
+
# If not streaming live to the terminal, don't redraw during capture.
|
|
95
|
+
return unless @stream
|
|
55
96
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
97
|
+
cols = if defined?(TTY::Screen)
|
|
98
|
+
TTY::Screen.columns
|
|
99
|
+
else
|
|
100
|
+
IO.console.winsize[1]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
display_lines = Array.new(@lines, '')
|
|
104
|
+
if buf.empty?
|
|
105
|
+
# leave display_lines as blanks
|
|
106
|
+
elsif buf.size <= @lines
|
|
107
|
+
buf.each_with_index { |l, i| display_lines[i] = l.to_s }
|
|
108
|
+
else
|
|
109
|
+
buf.last(@lines).each_with_index { |l, i| display_lines[i] = l.to_s }
|
|
61
110
|
end
|
|
62
111
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
112
|
+
if defined?(TTY::Cursor)
|
|
113
|
+
cursor = TTY::Cursor
|
|
114
|
+
io.print cursor.save
|
|
115
|
+
|
|
116
|
+
if @position == :above
|
|
117
|
+
io.print cursor.up(@lines)
|
|
118
|
+
else
|
|
119
|
+
io.print cursor.down(1)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
display_lines.each_with_index do |line, idx|
|
|
123
|
+
io.print cursor.clear_line
|
|
124
|
+
io.print line[0, cols]
|
|
125
|
+
io.print cursor.down(1) unless idx == display_lines.length - 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
io.print cursor.restore
|
|
129
|
+
debug_log('redraw finished (TTY)')
|
|
130
|
+
else
|
|
131
|
+
io.print "\e7"
|
|
132
|
+
|
|
133
|
+
if @position == :above
|
|
134
|
+
io.print "\e[#{@lines}A"
|
|
135
|
+
else
|
|
136
|
+
io.print "\e[1B"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
display_lines.each_with_index do |line, idx|
|
|
140
|
+
io.print "\e[2K\r"
|
|
141
|
+
io.print line[0, cols]
|
|
142
|
+
io.print "\e[1B" unless idx == display_lines.length - 1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
io.print "\e8"
|
|
146
|
+
debug_log('redraw finished (ANSI)')
|
|
71
147
|
end
|
|
72
|
-
|
|
148
|
+
|
|
73
149
|
io.flush
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
debug_log("redraw error: #{e.class}: #{e.message}")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Flush the buffered lines to the given IO (defaults to STDOUT).
|
|
155
|
+
# This is used when capturing non-live output: capture silently during
|
|
156
|
+
# the run and emit all captured output at the end.
|
|
157
|
+
def flush_to(io = $stdout)
|
|
158
|
+
buf = lines
|
|
159
|
+
return if buf.empty?
|
|
160
|
+
|
|
161
|
+
begin
|
|
162
|
+
buf.each do |line|
|
|
163
|
+
io.puts(line)
|
|
164
|
+
end
|
|
165
|
+
io.flush
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
debug_log("flush_to error: #{e.class}: #{e.message}")
|
|
168
|
+
end
|
|
74
169
|
end
|
|
75
170
|
|
|
76
171
|
private
|
|
@@ -78,13 +173,16 @@ module RubyProgress
|
|
|
78
173
|
def spawn_and_read
|
|
79
174
|
PTY.spawn(@command) do |reader, _writer, pid|
|
|
80
175
|
@child_pid = pid
|
|
176
|
+
debug_log("spawned pid=#{pid} cmd=#{@command}")
|
|
177
|
+
|
|
81
178
|
until reader.eof? || @stop
|
|
82
179
|
next unless reader.wait_readable(0.1)
|
|
83
180
|
|
|
84
181
|
chunk = reader.read_nonblock(4096, exception: false)
|
|
85
182
|
next if chunk.nil? || chunk.empty?
|
|
86
183
|
|
|
87
|
-
|
|
184
|
+
debug_log("read chunk=#{chunk.inspect}")
|
|
185
|
+
|
|
88
186
|
if @log_path && !@log_file
|
|
89
187
|
begin
|
|
90
188
|
FileUtils.mkdir_p(File.dirname(@log_path))
|
|
@@ -95,29 +193,26 @@ module RubyProgress
|
|
|
95
193
|
end
|
|
96
194
|
|
|
97
195
|
process_chunk(chunk)
|
|
196
|
+
debug_log("after process_chunk buffer_size=#{@buffer.size}")
|
|
197
|
+
|
|
98
198
|
next unless @log_file
|
|
99
199
|
|
|
100
200
|
begin
|
|
101
201
|
@log_file.write(chunk)
|
|
102
202
|
@log_file.flush
|
|
103
203
|
rescue StandardError
|
|
104
|
-
# ignore
|
|
204
|
+
# ignore
|
|
105
205
|
end
|
|
106
206
|
end
|
|
107
207
|
end
|
|
208
|
+
|
|
108
209
|
begin
|
|
109
210
|
Process.wait(@child_pid) if @child_pid
|
|
110
211
|
@exit_status = $CHILD_STATUS.exitstatus if $CHILD_STATUS
|
|
111
212
|
rescue StandardError
|
|
112
213
|
@exit_status = nil
|
|
113
214
|
ensure
|
|
114
|
-
|
|
115
|
-
begin
|
|
116
|
-
@log_file.close
|
|
117
|
-
rescue StandardError
|
|
118
|
-
nil
|
|
119
|
-
end
|
|
120
|
-
end
|
|
215
|
+
@log_file&.close
|
|
121
216
|
end
|
|
122
217
|
rescue Errno::EIO
|
|
123
218
|
# PTY finished
|
|
@@ -125,12 +220,49 @@ module RubyProgress
|
|
|
125
220
|
|
|
126
221
|
def process_chunk(chunk)
|
|
127
222
|
@buf_mutex.synchronize do
|
|
128
|
-
# split into lines, keep last N lines
|
|
129
223
|
chunk.each_line do |line|
|
|
130
224
|
@buffer << line.chomp
|
|
131
225
|
@buffer.shift while @buffer.size > @lines
|
|
132
226
|
end
|
|
133
227
|
end
|
|
228
|
+
|
|
229
|
+
debug_log("process_chunk: buffer=#{@buffer.inspect}")
|
|
230
|
+
redraw($stderr) if @stream
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
debug_log("process_chunk error: #{e.class}: #{e.message}")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def debug_log(msg)
|
|
236
|
+
return unless ENV['RUBY_PROGRESS_DEBUG'] || @debug
|
|
237
|
+
|
|
238
|
+
begin
|
|
239
|
+
File.open(@debug_path, 'a') do |f|
|
|
240
|
+
f.puts("#{Time.now.iso8601} PID=#{Process.pid} #{msg}")
|
|
241
|
+
end
|
|
242
|
+
rescue StandardError
|
|
243
|
+
# swallow logging errors
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def reserve_space(io = $stderr)
|
|
248
|
+
return unless io.tty?
|
|
249
|
+
|
|
250
|
+
debug_log("reserve_space called; position=#{@position.inspect}; lines=#{@lines}")
|
|
251
|
+
|
|
252
|
+
if @position == :above
|
|
253
|
+
# Insert lines above current cursor using CSI n L
|
|
254
|
+
io.print "\e[#{@lines}L"
|
|
255
|
+
debug_log("reserve_space: inserted #{@lines} lines for :above")
|
|
256
|
+
else
|
|
257
|
+
# Print newlines then move cursor back up so animation stays above
|
|
258
|
+
io.print("\n" * @lines)
|
|
259
|
+
io.print "\e[#{@lines}A"
|
|
260
|
+
debug_log("reserve_space: printed #{@lines} newlines and moved up #{@lines} for :below")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
io.flush
|
|
264
|
+
rescue StandardError => e
|
|
265
|
+
debug_log("reserve_space error: #{e.class}: #{e.message}")
|
|
134
266
|
end
|
|
135
267
|
end
|
|
136
268
|
end
|
data/lib/ruby-progress/worm.rb
CHANGED
|
@@ -65,6 +65,9 @@ module RubyProgress
|
|
|
65
65
|
@error_text = options[:error]
|
|
66
66
|
@show_checkmark = options[:checkmark] || false
|
|
67
67
|
@output_stdout = options[:stdout] || false
|
|
68
|
+
@output_lines = options[:output_lines]
|
|
69
|
+
@output_position = options[:output_position]
|
|
70
|
+
@output_live = options[:stdout_live] || false
|
|
68
71
|
@direction_mode = options[:direction] || :bidirectional
|
|
69
72
|
@start_chars, @end_chars = RubyProgress::Utils.parse_ends(options[:ends])
|
|
70
73
|
@running = false
|
|
@@ -197,67 +200,11 @@ module RubyProgress
|
|
|
197
200
|
}
|
|
198
201
|
end
|
|
199
202
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
message_part = @message && !@message.empty? ? "#{@message} " : ''
|
|
206
|
-
# Enhanced line clearing for better daemon mode behavior
|
|
207
|
-
$stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(position, direction)}#{@end_chars}"
|
|
208
|
-
$stderr.flush
|
|
209
|
-
|
|
210
|
-
sleep @speed
|
|
211
|
-
|
|
212
|
-
position += direction
|
|
213
|
-
if position >= @length - 1
|
|
214
|
-
if @direction_mode == :forward_only
|
|
215
|
-
position = 0
|
|
216
|
-
else
|
|
217
|
-
direction = -1
|
|
218
|
-
end
|
|
219
|
-
elsif position <= 0
|
|
220
|
-
direction = 1
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
# Enhanced animation loop for daemon mode with aggressive line clearing
|
|
226
|
-
def animation_loop_daemon_mode(stop_requested_proc: -> { false })
|
|
227
|
-
position = 0
|
|
228
|
-
direction = 1
|
|
229
|
-
frame_count = 0
|
|
230
|
-
|
|
231
|
-
while @running && !stop_requested_proc.call
|
|
232
|
-
message_part = @message && !@message.empty? ? "#{@message} " : ''
|
|
233
|
-
|
|
234
|
-
# Always clear current line
|
|
235
|
-
$stderr.print "\r\e[2K"
|
|
236
|
-
|
|
237
|
-
# Every few frames, use aggressive clearing to handle interruptions
|
|
238
|
-
if (frame_count % 10).zero?
|
|
239
|
-
$stderr.print "\e[1A\e[2K" # Move up and clear that line too (in case of interruption)
|
|
240
|
-
$stderr.print "\r" # Return to start
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
$stderr.print "#{message_part}#{generate_dots(position, direction)}"
|
|
244
|
-
$stderr.flush
|
|
245
|
-
|
|
246
|
-
sleep @speed
|
|
247
|
-
frame_count += 1
|
|
248
|
-
|
|
249
|
-
position += direction
|
|
250
|
-
if position >= @length - 1
|
|
251
|
-
if @direction_mode == :forward_only
|
|
252
|
-
position = 0
|
|
253
|
-
else
|
|
254
|
-
direction = -1
|
|
255
|
-
end
|
|
256
|
-
elsif position <= 0
|
|
257
|
-
direction = 1
|
|
258
|
-
end
|
|
259
|
-
end
|
|
260
|
-
end
|
|
203
|
+
# animation_loop and animation_loop_daemon_mode are implemented in
|
|
204
|
+
# the WormRunner module so they can share the redraw behavior that
|
|
205
|
+
# integrates with RubyProgress::OutputCapture. Do not redefine them
|
|
206
|
+
# here, otherwise the module implementations (which call
|
|
207
|
+
# @output_capture&.redraw) will be overridden.
|
|
261
208
|
|
|
262
209
|
def generate_dots(ripple_position, direction)
|
|
263
210
|
dots = Array.new(@length) { @style[:baseline] }
|