ruby-progress 1.2.0 → 1.3.1

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,282 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # rubocop:disable Metrics/ModuleLength
5
+ require 'open3'
6
+ require 'json'
7
+ require_relative '../utils'
8
+ require_relative '../output_capture'
9
+
10
+ # Runtime helper methods for RubyProgress::Worm
11
+ #
12
+ # These methods implement the interactive runtime behavior (animation
13
+ # loop, command execution, daemon mode, etc.) and were extracted from
14
+ # RubyProgress::Worm to reduce class length and improve readability.
15
+ module WormRunner
16
+ def animate(message: nil, success: nil, error: nil)
17
+ @message = message if message
18
+ @success_text = success if success
19
+ @error_text = error if error
20
+ @running = true
21
+
22
+ original_int_handler = Signal.trap('INT') do
23
+ @running = false
24
+ RubyProgress::Utils.clear_line
25
+ RubyProgress::Utils.show_cursor
26
+ exit 130
27
+ end
28
+
29
+ RubyProgress::Utils.hide_cursor
30
+ animation_thread = Thread.new { animation_loop }
31
+
32
+ begin
33
+ if block_given?
34
+ result = yield
35
+ @running = false
36
+ animation_thread.join
37
+ display_completion_message(@success_text, true)
38
+ result
39
+ else
40
+ animation_thread.join
41
+ end
42
+ rescue StandardError => e
43
+ @running = false
44
+ animation_thread.join
45
+ display_completion_message(@error_text || "Error: #{e.message}", false)
46
+ nil
47
+ ensure
48
+ $stderr.print "\r\e[2K"
49
+ RubyProgress::Utils.show_cursor
50
+ Signal.trap('INT', original_int_handler) if original_int_handler
51
+ end
52
+ end
53
+
54
+ def run_with_command
55
+ return unless @command
56
+
57
+ exit_code = 0
58
+ stdout_content = nil
59
+
60
+ begin
61
+ stdout_content = if $stdout.tty? && @output_stdout
62
+ oc = RubyProgress::OutputCapture.new(
63
+ command: @command,
64
+ lines: @output_lines || 3,
65
+ position: @output_position || :above
66
+ )
67
+ oc.start
68
+ @output_capture = oc
69
+ animate do
70
+ oc.wait
71
+ end
72
+ @output_capture = nil
73
+ oc.lines.join("\n")
74
+ else
75
+ animate do
76
+ Open3.popen3(@command) do |_stdin, stdout, stderr, wait_thr|
77
+ captured_stdout = stdout.read
78
+ stderr_content = stderr.read
79
+ exit_code = wait_thr.value.exitstatus
80
+
81
+ unless wait_thr.value.success?
82
+ error_msg = @error_text || "Command failed with exit code #{exit_code}"
83
+ error_msg += ": #{stderr_content.strip}" if stderr_content && !stderr_content.empty?
84
+ raise StandardError, error_msg
85
+ end
86
+
87
+ captured_stdout
88
+ end
89
+ end
90
+ end
91
+
92
+ puts stdout_content if @output_stdout && stdout_content
93
+ rescue StandardError
94
+ exit exit_code.nonzero? || 1
95
+ rescue Interrupt
96
+ exit 130
97
+ end
98
+ end
99
+
100
+ def run_indefinitely
101
+ original_int_handler = Signal.trap('INT') do
102
+ @running = false
103
+ RubyProgress::Utils.clear_line
104
+ RubyProgress::Utils.show_cursor
105
+ exit 130
106
+ end
107
+
108
+ @running = true
109
+ RubyProgress::Utils.hide_cursor
110
+
111
+ begin
112
+ animation_loop
113
+ ensure
114
+ RubyProgress::Utils.show_cursor
115
+ Signal.trap('INT', original_int_handler) if original_int_handler
116
+ end
117
+ end
118
+
119
+ def stop
120
+ @running = false
121
+ end
122
+
123
+ def run_daemon_mode(success_message: nil, show_checkmark: false, control_message_file: nil)
124
+ @running = true
125
+ stop_requested = false
126
+
127
+ original_int_handler = Signal.trap('INT') { stop_requested = true }
128
+ Signal.trap('USR1') { stop_requested = true }
129
+ Signal.trap('TERM') { stop_requested = true }
130
+ Signal.trap('HUP') { stop_requested = true }
131
+
132
+ RubyProgress::Utils.hide_cursor
133
+
134
+ begin
135
+ animation_loop_daemon_mode(stop_requested_proc: -> { stop_requested })
136
+ ensure
137
+ RubyProgress::Utils.clear_line
138
+ RubyProgress::Utils.show_cursor
139
+
140
+ final_message = success_message
141
+ final_checkmark = show_checkmark ? true : false
142
+ final_success = true
143
+
144
+ if control_message_file && File.exist?(control_message_file)
145
+ begin
146
+ data = JSON.parse(File.read(control_message_file))
147
+ final_message = data['message'] if data['message']
148
+ final_checkmark = data['checkmark'] if data.key?('checkmark')
149
+ final_success = data['success'] if data.key?('success')
150
+ rescue StandardError
151
+ # ignore parse errors
152
+ ensure
153
+ begin
154
+ File.delete(control_message_file)
155
+ rescue StandardError
156
+ nil
157
+ end
158
+ end
159
+ end
160
+
161
+ if final_message
162
+ RubyProgress::Utils.display_completion(
163
+ final_message,
164
+ success: final_success,
165
+ show_checkmark: final_checkmark,
166
+ output_stream: :stdout
167
+ )
168
+ end
169
+
170
+ Signal.trap('INT', original_int_handler) if original_int_handler
171
+ end
172
+ end
173
+
174
+ def animation_loop_step
175
+ return unless @running
176
+
177
+ @position ||= 0
178
+ @direction ||= 1
179
+
180
+ message_part = @message && !@message.empty? ? "#{@message} " : ''
181
+ @output_capture&.redraw($stderr)
182
+ $stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(@position, @direction)}#{@end_chars}"
183
+ $stderr.flush
184
+
185
+ sleep @speed
186
+
187
+ @position += @direction
188
+ if @position >= @length - 1
189
+ if @direction_mode == :forward_only
190
+ @position = 0
191
+ else
192
+ @direction = -1
193
+ end
194
+ elsif @position <= 0
195
+ @direction = 1
196
+ end
197
+ end
198
+
199
+ def animation_loop
200
+ position = 0
201
+ direction = 1
202
+
203
+ while @running
204
+ message_part = @message && !@message.empty? ? "#{@message} " : ''
205
+ @output_capture&.redraw($stderr)
206
+ $stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(position, direction)}#{@end_chars}"
207
+ $stderr.flush
208
+
209
+ sleep @speed
210
+
211
+ position += direction
212
+ if position >= @length - 1
213
+ if @direction_mode == :forward_only
214
+ position = 0
215
+ else
216
+ direction = -1
217
+ end
218
+ elsif position <= 0
219
+ direction = 1
220
+ end
221
+ end
222
+ end
223
+
224
+ def animation_loop_daemon_mode(stop_requested_proc: -> { false })
225
+ position = 0
226
+ direction = 1
227
+ frame_count = 0
228
+
229
+ while @running && !stop_requested_proc.call
230
+ message_part = @message && !@message.empty? ? "#{@message} " : ''
231
+ @output_capture&.redraw($stderr)
232
+
233
+ $stderr.print "\r\e[2K"
234
+
235
+ if (frame_count % 10).zero?
236
+ $stderr.print "\e[1A\e[2K"
237
+ $stderr.print "\r"
238
+ end
239
+
240
+ $stderr.print "#{message_part}#{generate_dots(position, direction)}"
241
+ $stderr.flush
242
+
243
+ sleep @speed
244
+ frame_count += 1
245
+
246
+ position += direction
247
+ if position >= @length - 1
248
+ if @direction_mode == :forward_only
249
+ position = 0
250
+ else
251
+ direction = -1
252
+ end
253
+ elsif position <= 0
254
+ direction = 1
255
+ end
256
+ end
257
+ end
258
+
259
+ def generate_dots(ripple_position, direction)
260
+ dots = Array.new(@length) { @style[:baseline] }
261
+
262
+ (0...@length).each do |i|
263
+ distance = (i - ripple_position).abs
264
+ case distance
265
+ when 0
266
+ dots[i] = @style[:peak]
267
+ when 1
268
+ if direction == -1
269
+ dots[i] = @style[:midline] if i > ripple_position
270
+ elsif i < ripple_position
271
+ dots[i] = @style[:midline]
272
+ end
273
+ else
274
+ dots[i] = @style[:baseline]
275
+ end
276
+ end
277
+
278
+ dots.join
279
+ end
280
+ end
281
+
282
+ # rubocop:enable Metrics/ModuleLength
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'fileutils'
4
5
 
5
6
  module RubyProgress
6
7
  module Daemon
@@ -14,6 +15,70 @@ module RubyProgress
14
15
  "#{pid_file}.msg"
15
16
  end
16
17
 
18
+ # Resolve a job directory for the daemon based on pid_file or name.
19
+ # If pid_file is '/tmp/ruby-progress/mytask.pid' -> jobs dir '/tmp/ruby-progress/mytask.jobs'
20
+ def job_dir_for_pid(pid_file)
21
+ base = File.basename(pid_file, '.*')
22
+ File.join(File.dirname(pid_file), "#{base}.jobs")
23
+ end
24
+
25
+ # Process available job files in job_dir. Each job is a JSON file with {"id","command","meta"}.
26
+ # This method polls the directory and yields each parsed job hash to the provided block.
27
+ def process_jobs(job_dir, poll_interval: 0.2)
28
+ FileUtils.mkdir_p(job_dir)
29
+
30
+ loop do
31
+ # Accept any job file ending in .json (UUID filenames are common)
32
+ # Ignore processed-* archives and temporary files (e.g., .tmp)
33
+ files = Dir.children(job_dir).select do |f|
34
+ f.end_with?('.json') && !f.start_with?('processed-')
35
+ end.sort
36
+
37
+ files.each do |f|
38
+ path = File.join(job_dir, f)
39
+ processing = "#{path}.processing"
40
+
41
+ # Claim the file atomically
42
+ begin
43
+ File.rename(path, processing)
44
+ rescue StandardError
45
+ next
46
+ end
47
+
48
+ job = begin
49
+ JSON.parse(File.read(processing))
50
+ rescue StandardError
51
+ FileUtils.rm_f(processing)
52
+ next
53
+ end
54
+
55
+ begin
56
+ yielded = yield(job)
57
+
58
+ # on success, write .result info and merge any returned info
59
+ result = { 'id' => job['id'], 'status' => 'done', 'time' => Time.now.to_i }
60
+ if yielded.is_a?(Hash)
61
+ # ensure string keys
62
+ extra = yielded.transform_keys(&:to_s)
63
+ result.merge!(extra)
64
+ end
65
+ File.write("#{processing}.result", result.to_json)
66
+ rescue StandardError => e
67
+ result = { 'id' => job['id'], 'status' => 'error', 'error' => e.message }
68
+ File.write("#{processing}.result", result.to_json)
69
+ ensure
70
+ begin
71
+ FileUtils.mv(processing, File.join(job_dir, "processed-#{f}"))
72
+ rescue StandardError
73
+ FileUtils.rm_f(processing)
74
+ end
75
+ end
76
+ end
77
+
78
+ sleep(poll_interval)
79
+ end
80
+ end
81
+
17
82
  def show_status(pid_file)
18
83
  if File.exist?(pid_file)
19
84
  pid = File.read(pid_file).strip
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyProgress
4
+ # Determinate progress bar with customizable fill styles
5
+ class Fill
6
+ # Built-in fill styles with empty and full characters
7
+ FILL_STYLES = {
8
+ blocks: { empty: '▱', full: '▰' },
9
+ classic: { empty: '-', full: '=' },
10
+ dots: { empty: '·', full: '●' },
11
+ squares: { empty: '□', full: '■' },
12
+ circles: { empty: '○', full: '●' },
13
+ ascii: { empty: '.', full: '#' },
14
+ bars: { empty: '░', full: '█' },
15
+ arrows: { empty: '▷', full: '▶' },
16
+ stars: { empty: '☆', full: '★' }
17
+ }.freeze
18
+
19
+ attr_reader :length, :style, :current_progress, :start_chars, :end_chars
20
+ attr_accessor :success_message, :error_message
21
+
22
+ def initialize(options = {})
23
+ @length = options[:length] || 20
24
+ @style = parse_style(options[:style] || :blocks)
25
+ @current_progress = 0
26
+ @success_message = options[:success]
27
+ @error_message = options[:error]
28
+ @output_capture = options[:output_capture]
29
+
30
+ # Parse --ends characters
31
+ if options[:ends]
32
+ @start_chars, @end_chars = RubyProgress::Utils.parse_ends(options[:ends])
33
+ else
34
+ @start_chars = ''
35
+ @end_chars = ''
36
+ end
37
+ end
38
+
39
+ # Advance the progress bar by one step or specified increment
40
+ def advance(increment: 1, percent: nil)
41
+ @current_progress = if percent
42
+ [@length * percent / 100.0, @length].min.round
43
+ else
44
+ [@current_progress + increment, @length].min
45
+ end
46
+
47
+ render
48
+ completed?
49
+ end
50
+
51
+ # Set progress to specific percentage (0-100)
52
+ def percent=(percent)
53
+ percent = percent.clamp(0, 100) # Clamp between 0-100
54
+ @current_progress = (@length * percent / 100.0).round
55
+ render
56
+ completed?
57
+ end
58
+
59
+ # Check if progress bar is complete
60
+ def completed?
61
+ @current_progress >= @length
62
+ end
63
+
64
+ # Get current progress as percentage
65
+ def percent
66
+ (@current_progress.to_f / @length * 100).round(1)
67
+ end
68
+
69
+ # Get current progress as float (0.0-100.0) - for scripting
70
+ def current
71
+ (@current_progress.to_f / @length * 100).round(1)
72
+ end
73
+
74
+ # Get detailed progress status information
75
+ def report
76
+ {
77
+ progress: [@current_progress, @length],
78
+ percent: current,
79
+ completed: completed?,
80
+ style: @style
81
+ }
82
+ end
83
+
84
+ # Render the current progress bar to stderr
85
+ def render
86
+ # First redraw captured output (if any) so it appears above/below the bar
87
+ @output_capture&.redraw($stderr)
88
+
89
+ filled = @style[:full] * @current_progress
90
+ empty = @style[:empty] * (@length - @current_progress)
91
+ bar = "#{@start_chars}#{filled}#{empty}#{@end_chars}"
92
+
93
+ $stderr.print "\r\e[2K#{bar}"
94
+ $stderr.flush
95
+ end
96
+
97
+ # Complete the progress bar and show success message
98
+ def complete(message = nil)
99
+ @current_progress = @length
100
+ render
101
+
102
+ completion_message = message || @success_message
103
+ if completion_message
104
+ RubyProgress::Utils.display_completion(
105
+ completion_message,
106
+ success: true,
107
+ show_checkmark: true,
108
+ output_stream: :warn
109
+ )
110
+ else
111
+ $stderr.puts # Just add a newline if no message
112
+ end
113
+ end
114
+
115
+ # Cancel the progress bar and show error message
116
+ def cancel(message = nil)
117
+ $stderr.print "\r\e[2K" # Clear the progress bar
118
+ $stderr.flush
119
+
120
+ error_msg = message || @error_message
121
+ return unless error_msg
122
+
123
+ RubyProgress::Utils.display_completion(
124
+ error_msg,
125
+ success: false,
126
+ show_checkmark: true,
127
+ output_stream: :warn
128
+ )
129
+ end
130
+
131
+ # Hide or show the cursor (delegated to Utils)
132
+ def self.hide_cursor
133
+ RubyProgress::Utils.hide_cursor
134
+ end
135
+
136
+ def self.show_cursor
137
+ RubyProgress::Utils.show_cursor
138
+ end
139
+
140
+ # Progress with block interface for library usage
141
+ def self.progress(options = {}, &block)
142
+ return unless block_given?
143
+
144
+ fill_bar = new(options)
145
+ Fill.hide_cursor
146
+
147
+ begin
148
+ fill_bar.render # Show initial empty bar
149
+
150
+ # Call the block with the fill bar instance
151
+ result = block.call(fill_bar)
152
+
153
+ # Handle completion based on block result or bar state
154
+ if fill_bar.completed? || result == true || result.nil?
155
+ fill_bar.complete
156
+ elsif result == false
157
+ fill_bar.cancel
158
+ end
159
+
160
+ result
161
+ rescue StandardError => e
162
+ fill_bar.cancel("Error: #{e.message}")
163
+ raise
164
+ ensure
165
+ Fill.show_cursor
166
+ end
167
+ end
168
+
169
+ private
170
+
171
+ # Parse style option into empty/full character hash
172
+ def parse_style(style_option)
173
+ case style_option
174
+ when Symbol, String
175
+ style_name = style_option.to_sym
176
+ if FILL_STYLES.key?(style_name)
177
+ FILL_STYLES[style_name]
178
+ else
179
+ FILL_STYLES[:blocks] # Default fallback
180
+ end
181
+ when Hash
182
+ # Allow direct hash specification: { empty: '.', full: '#' }
183
+ {
184
+ empty: style_option[:empty] || '.',
185
+ full: style_option[:full] || '#'
186
+ }
187
+ else
188
+ FILL_STYLES[:blocks] # Default fallback
189
+ end
190
+ end
191
+
192
+ class << self
193
+ # Parse custom style string like "custom=.#"
194
+ def parse_custom_style(style_string)
195
+ if style_string.start_with?('custom=')
196
+ chars = style_string.sub('custom=', '')
197
+
198
+ # Handle multi-byte characters properly
199
+ char_array = chars.chars
200
+
201
+ if char_array.length == 2
202
+ { empty: char_array[0], full: char_array[1] }
203
+ else
204
+ # Invalid custom style, return default
205
+ FILL_STYLES[:blocks]
206
+ end
207
+ else
208
+ # Try to find built-in style
209
+ style_name = style_string.to_sym
210
+ FILL_STYLES[style_name] || FILL_STYLES[:blocks]
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end