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.
@@ -2,27 +2,69 @@
2
2
 
3
3
  require 'pty'
4
4
  require 'io/console'
5
- require 'English'
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
- # Simple PTY-based output capture that reserves a small area of the terminal
9
- # for printing captured output while the animation is drawn separately.
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
- @lines = lines
16
- @position = position
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 and return a thread that manages capture.
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 buffered output into the terminal above/below the current cursor
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
- _rows, cols = IO.console.winsize
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
- # Ensure we have exactly @lines entries (pad with empty strings)
57
- display_lines = Array.new(@lines) { '' }
58
- start = [0, buf.size - @lines].max
59
- buf[start, @lines]&.each_with_index do |l, i|
60
- display_lines[i + (@lines - [buf.size, @lines].min)] = l.to_s
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
- # Save cursor, move up N lines, clear and print buffer, restore cursor
64
- io.print "\e[s" # save position
65
- io.print "\e[#{@lines}A" # move up @lines
66
- display_lines.each do |line|
67
- io.print "\e[2K" # clear line
68
- io.print "\r" # move cursor to start of line
69
- io.print line[0, cols]
70
- io.print "\n"
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
- io.print "\e[u" # restore
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
- # lazily open log file if requested
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 logging errors
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
- if @log_file
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module RubyProgress
4
4
  # Main gem version
5
- VERSION = '1.3.2'
5
+ VERSION = '1.3.4'
6
6
 
7
7
  # Component-specific versions (patch bumps)
8
8
  WORM_VERSION = '1.1.4'
@@ -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
- def animation_loop
201
- position = 0
202
- direction = 1
203
-
204
- while @running
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] }