ruby-progress 1.1.9 → 1.2.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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'optparse'
5
+ require_relative 'worm_options'
6
+
7
+ # Enhanced Worm CLI (extracted from bin/prg)
8
+ module WormCLI
9
+ def self.run
10
+ options = WormCLI::Options.parse_cli_options
11
+
12
+ if options[:status]
13
+ pid_file = resolve_pid_file(options, :status_name)
14
+ RubyProgress::Daemon.show_status(pid_file)
15
+ exit
16
+ elsif options[:stop]
17
+ pid_file = resolve_pid_file(options, :stop_name)
18
+ stop_msg = options[:stop_error] || options[:stop_success]
19
+ is_error = !options[:stop_error].nil?
20
+ RubyProgress::Daemon.stop_daemon_by_pid_file(
21
+ pid_file,
22
+ message: stop_msg,
23
+ checkmark: options[:stop_checkmark],
24
+ error: is_error
25
+ )
26
+ exit
27
+ elsif options[:daemon]
28
+ # Detach before starting daemon logic so there's no tracked shell job
29
+ PrgCLI.daemonize
30
+ run_daemon_mode(options)
31
+ else
32
+ progress = RubyProgress::Worm.new(options)
33
+
34
+ if options[:command]
35
+ progress.run_with_command
36
+ else
37
+ progress.run_indefinitely
38
+ end
39
+ end
40
+ end
41
+
42
+ def self.run_daemon_mode(options)
43
+ pid_file = resolve_pid_file(options, :daemon_name)
44
+ FileUtils.mkdir_p(File.dirname(pid_file))
45
+ File.write(pid_file, Process.pid.to_s)
46
+
47
+ progress = RubyProgress::Worm.new(options)
48
+
49
+ begin
50
+ progress.run_daemon_mode(
51
+ success_message: options[:success],
52
+ show_checkmark: options[:checkmark],
53
+ control_message_file: RubyProgress::Daemon.control_message_file(pid_file)
54
+ )
55
+ ensure
56
+ FileUtils.rm_f(pid_file)
57
+ end
58
+ end
59
+
60
+ def self.resolve_pid_file(options, name_key)
61
+ return options[:pid_file] if options[:pid_file]
62
+
63
+ if options[name_key]
64
+ "/tmp/ruby-progress/#{options[name_key]}.pid"
65
+ else
66
+ RubyProgress::Daemon.default_pid_file
67
+ end
68
+ end
69
+
70
+ # parse_cli_options is intentionally kept together; further extraction possible
71
+ # parse_cli_options is intentionally delegated to WormCLI::Options
72
+ def self.parse_cli_options
73
+ WormCLI::Options.parse_cli_options
74
+ end
75
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module WormCLI
6
+ # Option parsing helpers for the Worm subcommand.
7
+ #
8
+ # Keeps the CLI option definitions for `prg worm` extracted from
9
+ # the main dispatcher to keep the CLI module small and focused.
10
+ module Options
11
+ def self.parse_cli_options
12
+ options = {}
13
+
14
+ begin
15
+ OptionParser.new do |opts|
16
+ opts.banner = 'Usage: prg worm [options]'
17
+ opts.separator ''
18
+ opts.separator 'Animation Options:'
19
+
20
+ opts.on('-s', '--speed SPEED', 'Animation speed (1-10, fast/medium/slow, or f/m/s)') do |speed|
21
+ options[:speed] = speed
22
+ end
23
+
24
+ opts.on('-m', '--message MESSAGE', 'Message to display before animation') do |message|
25
+ options[:message] = message
26
+ end
27
+
28
+ opts.on('-l', '--length LENGTH', Integer, 'Number of dots to display') do |length|
29
+ options[:length] = length
30
+ end
31
+
32
+ opts.on('--style STYLE', 'Animation style (circles/blocks/geometric, c/b/g, or custom=abc)') do |style|
33
+ options[:style] = style
34
+ end
35
+
36
+ opts.on('-d', '--direction DIRECTION', 'Animation direction (forward/bidirectional or f/b)') do |direction|
37
+ options[:direction] = direction =~ /^f/i ? :forward_only : :bidirectional
38
+ end
39
+
40
+ opts.on('--ends CHARS', 'Start/end characters (even number of chars, split in half)') do |chars|
41
+ options[:ends] = chars
42
+ end
43
+
44
+ opts.separator ''
45
+ opts.separator 'Command Execution:'
46
+
47
+ opts.on('-c', '--command COMMAND', 'Command to run (optional - runs indefinitely without)') do |command|
48
+ options[:command] = command
49
+ end
50
+
51
+ opts.on('--success MESSAGE', 'Success message to display') do |text|
52
+ options[:success] = text
53
+ end
54
+
55
+ opts.on('--error MESSAGE', 'Error message to display') do |text|
56
+ options[:error] = text
57
+ end
58
+
59
+ opts.on('--checkmark', 'Show checkmarks (✅ success, 🛑 failure)') do
60
+ options[:checkmark] = true
61
+ end
62
+
63
+ opts.on('--stdout', 'Output captured command result to STDOUT') do
64
+ options[:stdout] = true
65
+ end
66
+
67
+ opts.separator ''
68
+ opts.separator 'Daemon Mode:'
69
+
70
+ opts.on('--daemon', 'Run in background daemon mode') do
71
+ options[:daemon] = true
72
+ end
73
+
74
+ opts.on('--daemon-as NAME', 'Run in daemon mode with custom name (creates /tmp/ruby-progress/NAME.pid)') do |name|
75
+ options[:daemon] = true
76
+ options[:daemon_name] = name
77
+ end
78
+
79
+ opts.on('--pid-file FILE', 'Write process ID to file (default: /tmp/ruby-progress/progress.pid)') do |file|
80
+ options[:pid_file] = file
81
+ end
82
+
83
+ opts.on('--stop', 'Stop daemon (uses default PID file unless --pid-file specified)') do
84
+ options[:stop] = true
85
+ end
86
+ opts.on('--stop-id NAME', 'Stop daemon by name (automatically implies --stop)') do |name|
87
+ options[:stop] = true
88
+ options[:stop_name] = name
89
+ end
90
+ opts.on('--status', 'Show daemon status (uses default PID file unless --pid-file specified)') do
91
+ options[:status] = true
92
+ end
93
+ opts.on('--status-id NAME', 'Show daemon status by name') do |name|
94
+ options[:status] = true
95
+ options[:status_name] = name
96
+ end
97
+ opts.on('--stop-success MESSAGE', 'Stop daemon with success message (automatically implies --stop)') do |msg|
98
+ options[:stop] = true
99
+ options[:stop_success] = msg
100
+ end
101
+ opts.on('--stop-error MESSAGE', 'Stop daemon with error message (automatically implies --stop)') do |msg|
102
+ options[:stop] = true
103
+ options[:stop_error] = msg
104
+ end
105
+ opts.on('--stop-checkmark', 'When stopping, include a success checkmark') { options[:stop_checkmark] = true }
106
+
107
+ opts.on('--stop-all', 'Stop all prg worm processes') do
108
+ success = PrgCLI.stop_subcommand_processes('worm')
109
+ exit(success ? 0 : 1)
110
+ end
111
+
112
+ opts.on('--stop-pid FILE', 'Stop daemon by reading PID from file (deprecated: use --stop [--pid-file])') do |file|
113
+ RubyProgress::Daemon.stop_daemon_by_pid_file(file)
114
+ exit
115
+ end
116
+
117
+ opts.separator ''
118
+ opts.separator 'Daemon notes:'
119
+ opts.separator ' - Do not append &; prg detaches itself and returns immediately.'
120
+ opts.separator ' - Use --daemon-as NAME for named daemons, or --stop-id/--status-id for named control.'
121
+
122
+ opts.separator ''
123
+ opts.separator 'General:'
124
+
125
+ opts.on('--show-styles', 'Show available worm styles with visual previews') do
126
+ PrgCLI.show_worm_styles
127
+ exit
128
+ end
129
+
130
+ opts.on('--stop-all', 'Stop all prg worm processes') do
131
+ success = PrgCLI.stop_subcommand_processes('worm')
132
+ exit(success ? 0 : 1)
133
+ end
134
+
135
+ opts.on('-v', '--version', 'Show version') do
136
+ puts "Worm version #{RubyProgress::VERSION}"
137
+ exit
138
+ end
139
+
140
+ opts.on('-h', '--help', 'Show this help') do
141
+ puts opts
142
+ exit
143
+ end
144
+ end.parse!
145
+ rescue OptionParser::InvalidOption => e
146
+ puts "Invalid option: #{e.args.first}"
147
+ puts ''
148
+ puts 'Usage: prg worm [options]'
149
+ puts "Run 'prg worm --help' for more information."
150
+ exit 1
151
+ end
152
+
153
+ options
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'open3'
5
+ require 'json'
6
+ require_relative '../utils'
7
+
8
+ # Runtime helper methods for RubyProgress::Worm
9
+ #
10
+ # These methods implement the interactive runtime behavior (animation
11
+ # loop, command execution, daemon mode, etc.) and were extracted from
12
+ # RubyProgress::Worm to reduce class length and improve readability.
13
+ module WormRunner
14
+ def animate(message: nil, success: nil, error: nil)
15
+ @message = message if message
16
+ @success_text = success if success
17
+ @error_text = error if error
18
+ @running = true
19
+
20
+ original_int_handler = Signal.trap('INT') do
21
+ @running = false
22
+ RubyProgress::Utils.clear_line
23
+ RubyProgress::Utils.show_cursor
24
+ exit 130
25
+ end
26
+
27
+ RubyProgress::Utils.hide_cursor
28
+ animation_thread = Thread.new { animation_loop }
29
+
30
+ begin
31
+ if block_given?
32
+ result = yield
33
+ @running = false
34
+ animation_thread.join
35
+ display_completion_message(@success_text, true)
36
+ result
37
+ else
38
+ animation_thread.join
39
+ end
40
+ rescue StandardError => e
41
+ @running = false
42
+ animation_thread.join
43
+ display_completion_message(@error_text || "Error: #{e.message}", false)
44
+ nil
45
+ ensure
46
+ $stderr.print "\r\e[2K"
47
+ RubyProgress::Utils.show_cursor
48
+ Signal.trap('INT', original_int_handler) if original_int_handler
49
+ end
50
+ end
51
+
52
+ def run_with_command
53
+ return unless @command
54
+
55
+ exit_code = 0
56
+ stdout_content = nil
57
+
58
+ begin
59
+ stdout_content = animate do
60
+ Open3.popen3(@command) do |_stdin, stdout, stderr, wait_thr|
61
+ captured_stdout = stdout.read
62
+ stderr_content = stderr.read
63
+ exit_code = wait_thr.value.exitstatus
64
+
65
+ unless wait_thr.value.success?
66
+ error_msg = @error_text || "Command failed with exit code #{exit_code}"
67
+ error_msg += ": #{stderr_content.strip}" if stderr_content && !stderr_content.empty?
68
+ raise StandardError, error_msg
69
+ end
70
+
71
+ captured_stdout
72
+ end
73
+ end
74
+
75
+ puts stdout_content if @output_stdout && stdout_content
76
+ rescue StandardError
77
+ exit exit_code.nonzero? || 1
78
+ rescue Interrupt
79
+ exit 130
80
+ end
81
+ end
82
+
83
+ def run_indefinitely
84
+ original_int_handler = Signal.trap('INT') do
85
+ @running = false
86
+ RubyProgress::Utils.clear_line
87
+ RubyProgress::Utils.show_cursor
88
+ exit 130
89
+ end
90
+
91
+ @running = true
92
+ RubyProgress::Utils.hide_cursor
93
+
94
+ begin
95
+ animation_loop
96
+ ensure
97
+ RubyProgress::Utils.show_cursor
98
+ Signal.trap('INT', original_int_handler) if original_int_handler
99
+ end
100
+ end
101
+
102
+ def stop
103
+ @running = false
104
+ end
105
+
106
+ def run_daemon_mode(success_message: nil, show_checkmark: false, control_message_file: nil)
107
+ @running = true
108
+ stop_requested = false
109
+
110
+ original_int_handler = Signal.trap('INT') { stop_requested = true }
111
+ Signal.trap('USR1') { stop_requested = true }
112
+ Signal.trap('TERM') { stop_requested = true }
113
+ Signal.trap('HUP') { stop_requested = true }
114
+
115
+ RubyProgress::Utils.hide_cursor
116
+
117
+ begin
118
+ animation_loop_daemon_mode(stop_requested_proc: -> { stop_requested })
119
+ ensure
120
+ RubyProgress::Utils.clear_line
121
+ RubyProgress::Utils.show_cursor
122
+
123
+ final_message = success_message
124
+ final_checkmark = show_checkmark ? true : false
125
+ final_success = true
126
+
127
+ if control_message_file && File.exist?(control_message_file)
128
+ begin
129
+ data = JSON.parse(File.read(control_message_file))
130
+ final_message = data['message'] if data['message']
131
+ final_checkmark = data['checkmark'] if data.key?('checkmark')
132
+ final_success = data['success'] if data.key?('success')
133
+ rescue StandardError
134
+ # ignore parse errors
135
+ ensure
136
+ begin
137
+ File.delete(control_message_file)
138
+ rescue StandardError
139
+ nil
140
+ end
141
+ end
142
+ end
143
+
144
+ if final_message
145
+ RubyProgress::Utils.display_completion(
146
+ final_message,
147
+ success: final_success,
148
+ show_checkmark: final_checkmark,
149
+ output_stream: :stdout
150
+ )
151
+ end
152
+
153
+ Signal.trap('INT', original_int_handler) if original_int_handler
154
+ end
155
+ end
156
+
157
+ def animation_loop_step
158
+ return unless @running
159
+
160
+ @position ||= 0
161
+ @direction ||= 1
162
+
163
+ message_part = @message && !@message.empty? ? "#{@message} " : ''
164
+ $stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(@position, @direction)}#{@end_chars}"
165
+ $stderr.flush
166
+
167
+ sleep @speed
168
+
169
+ @position += @direction
170
+ if @position >= @length - 1
171
+ if @direction_mode == :forward_only
172
+ @position = 0
173
+ else
174
+ @direction = -1
175
+ end
176
+ elsif @position <= 0
177
+ @direction = 1
178
+ end
179
+ end
180
+
181
+ def animation_loop
182
+ position = 0
183
+ direction = 1
184
+
185
+ while @running
186
+ message_part = @message && !@message.empty? ? "#{@message} " : ''
187
+ $stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(position, direction)}#{@end_chars}"
188
+ $stderr.flush
189
+
190
+ sleep @speed
191
+
192
+ position += direction
193
+ if position >= @length - 1
194
+ if @direction_mode == :forward_only
195
+ position = 0
196
+ else
197
+ direction = -1
198
+ end
199
+ elsif position <= 0
200
+ direction = 1
201
+ end
202
+ end
203
+ end
204
+
205
+ def animation_loop_daemon_mode(stop_requested_proc: -> { false })
206
+ position = 0
207
+ direction = 1
208
+ frame_count = 0
209
+
210
+ while @running && !stop_requested_proc.call
211
+ message_part = @message && !@message.empty? ? "#{@message} " : ''
212
+
213
+ $stderr.print "\r\e[2K"
214
+
215
+ if (frame_count % 10).zero?
216
+ $stderr.print "\e[1A\e[2K"
217
+ $stderr.print "\r"
218
+ end
219
+
220
+ $stderr.print "#{message_part}#{generate_dots(position, direction)}"
221
+ $stderr.flush
222
+
223
+ sleep @speed
224
+ frame_count += 1
225
+
226
+ position += direction
227
+ if position >= @length - 1
228
+ if @direction_mode == :forward_only
229
+ position = 0
230
+ else
231
+ direction = -1
232
+ end
233
+ elsif position <= 0
234
+ direction = 1
235
+ end
236
+ end
237
+ end
238
+
239
+ def generate_dots(ripple_position, direction)
240
+ dots = Array.new(@length) { @style[:baseline] }
241
+
242
+ (0...@length).each do |i|
243
+ distance = (i - ripple_position).abs
244
+ case distance
245
+ when 0
246
+ dots[i] = @style[:peak]
247
+ when 1
248
+ if direction == -1
249
+ dots[i] = @style[:midline] if i > ripple_position
250
+ elsif i < ripple_position
251
+ dots[i] = @style[:midline]
252
+ end
253
+ else
254
+ dots[i] = @style[:baseline]
255
+ end
256
+ end
257
+
258
+ dots.join
259
+ end
260
+ end
@@ -0,0 +1,211 @@
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
+
29
+ # Parse --ends characters
30
+ if options[:ends]
31
+ @start_chars, @end_chars = RubyProgress::Utils.parse_ends(options[:ends])
32
+ else
33
+ @start_chars = ''
34
+ @end_chars = ''
35
+ end
36
+ end
37
+
38
+ # Advance the progress bar by one step or specified increment
39
+ def advance(increment: 1, percent: nil)
40
+ @current_progress = if percent
41
+ [@length * percent / 100.0, @length].min.round
42
+ else
43
+ [@current_progress + increment, @length].min
44
+ end
45
+
46
+ render
47
+ completed?
48
+ end
49
+
50
+ # Set progress to specific percentage (0-100)
51
+ def percent=(percent)
52
+ percent = percent.clamp(0, 100) # Clamp between 0-100
53
+ @current_progress = (@length * percent / 100.0).round
54
+ render
55
+ completed?
56
+ end
57
+
58
+ # Check if progress bar is complete
59
+ def completed?
60
+ @current_progress >= @length
61
+ end
62
+
63
+ # Get current progress as percentage
64
+ def percent
65
+ (@current_progress.to_f / @length * 100).round(1)
66
+ end
67
+
68
+ # Get current progress as float (0.0-100.0) - for scripting
69
+ def current
70
+ (@current_progress.to_f / @length * 100).round(1)
71
+ end
72
+
73
+ # Get detailed progress status information
74
+ def report
75
+ {
76
+ progress: [@current_progress, @length],
77
+ percent: current,
78
+ completed: completed?,
79
+ style: @style
80
+ }
81
+ end
82
+
83
+ # Render the current progress bar to stderr
84
+ def render
85
+ filled = @style[:full] * @current_progress
86
+ empty = @style[:empty] * (@length - @current_progress)
87
+ bar = "#{@start_chars}#{filled}#{empty}#{@end_chars}"
88
+
89
+ $stderr.print "\r\e[2K#{bar}"
90
+ $stderr.flush
91
+ end
92
+
93
+ # Complete the progress bar and show success message
94
+ def complete(message = nil)
95
+ @current_progress = @length
96
+ render
97
+
98
+ completion_message = message || @success_message
99
+ if completion_message
100
+ RubyProgress::Utils.display_completion(
101
+ completion_message,
102
+ success: true,
103
+ show_checkmark: true,
104
+ output_stream: :warn
105
+ )
106
+ else
107
+ $stderr.puts # Just add a newline if no message
108
+ end
109
+ end
110
+
111
+ # Cancel the progress bar and show error message
112
+ def cancel(message = nil)
113
+ $stderr.print "\r\e[2K" # Clear the progress bar
114
+ $stderr.flush
115
+
116
+ error_msg = message || @error_message
117
+ return unless error_msg
118
+
119
+ RubyProgress::Utils.display_completion(
120
+ error_msg,
121
+ success: false,
122
+ show_checkmark: true,
123
+ output_stream: :warn
124
+ )
125
+ end
126
+
127
+ # Hide or show the cursor (delegated to Utils)
128
+ def self.hide_cursor
129
+ RubyProgress::Utils.hide_cursor
130
+ end
131
+
132
+ def self.show_cursor
133
+ RubyProgress::Utils.show_cursor
134
+ end
135
+
136
+ # Progress with block interface for library usage
137
+ def self.progress(options = {}, &block)
138
+ return unless block_given?
139
+
140
+ fill_bar = new(options)
141
+ Fill.hide_cursor
142
+
143
+ begin
144
+ fill_bar.render # Show initial empty bar
145
+
146
+ # Call the block with the fill bar instance
147
+ result = block.call(fill_bar)
148
+
149
+ # Handle completion based on block result or bar state
150
+ if fill_bar.completed? || result == true || result.nil?
151
+ fill_bar.complete
152
+ elsif result == false
153
+ fill_bar.cancel
154
+ end
155
+
156
+ result
157
+ rescue StandardError => e
158
+ fill_bar.cancel("Error: #{e.message}")
159
+ raise
160
+ ensure
161
+ Fill.show_cursor
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ # Parse style option into empty/full character hash
168
+ def parse_style(style_option)
169
+ case style_option
170
+ when Symbol, String
171
+ style_name = style_option.to_sym
172
+ if FILL_STYLES.key?(style_name)
173
+ FILL_STYLES[style_name]
174
+ else
175
+ FILL_STYLES[:blocks] # Default fallback
176
+ end
177
+ when Hash
178
+ # Allow direct hash specification: { empty: '.', full: '#' }
179
+ {
180
+ empty: style_option[:empty] || '.',
181
+ full: style_option[:full] || '#'
182
+ }
183
+ else
184
+ FILL_STYLES[:blocks] # Default fallback
185
+ end
186
+ end
187
+
188
+ class << self
189
+ # Parse custom style string like "custom=.#"
190
+ def parse_custom_style(style_string)
191
+ if style_string.start_with?('custom=')
192
+ chars = style_string.sub('custom=', '')
193
+
194
+ # Handle multi-byte characters properly
195
+ char_array = chars.chars
196
+
197
+ if char_array.length == 2
198
+ { empty: char_array[0], full: char_array[1] }
199
+ else
200
+ # Invalid custom style, return default
201
+ FILL_STYLES[:blocks]
202
+ end
203
+ else
204
+ # Try to find built-in style
205
+ style_name = style_string.to_sym
206
+ FILL_STYLES[style_name] || FILL_STYLES[:blocks]
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end