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,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'fileutils'
5
+ require_relative 'cli/fill_options'
6
+ require_relative 'output_capture'
7
+
8
+ module RubyProgress
9
+ # CLI module for Fill command
10
+ module FillCLI
11
+ class << self
12
+ def run
13
+ trap('INT') do
14
+ Utils.show_cursor
15
+ exit
16
+ end
17
+
18
+ options = RubyProgress::FillCLI::Options.parse_cli_options
19
+
20
+ # Handle basic output flags first
21
+ if options[:help]
22
+ puts RubyProgress::FillCLI::Options.help_text
23
+ exit
24
+ end
25
+
26
+ if options[:version]
27
+ puts "Fill version #{RubyProgress::FILL_VERSION}"
28
+ exit
29
+ end
30
+
31
+ if options[:show_styles]
32
+ show_fill_styles
33
+ exit
34
+ end
35
+
36
+ # Handle daemon control first
37
+ if options[:status] || options[:stop]
38
+ pid_file = options[:pid_file] || '/tmp/ruby-progress/fill.pid'
39
+ if options[:status]
40
+ Daemon.show_status(pid_file)
41
+ else
42
+ Daemon.stop_daemon_by_pid_file(pid_file)
43
+ end
44
+ exit
45
+ end
46
+
47
+ # Parse style option
48
+ parsed_style = parse_fill_style(options[:style])
49
+
50
+ if options[:daemon]
51
+ run_daemon_mode(options, parsed_style)
52
+ elsif options[:current]
53
+ show_current_percentage(options, parsed_style)
54
+ elsif options[:report]
55
+ show_progress_report(options, parsed_style)
56
+ elsif options[:advance] || options[:complete] || options[:cancel]
57
+ handle_progress_commands(options, parsed_style)
58
+ else
59
+ run_auto_advance_mode(options, parsed_style)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def parse_fill_style(style_option)
66
+ case style_option
67
+ when String
68
+ if style_option.start_with?('custom=')
69
+ Fill.parse_custom_style(style_option)
70
+ else
71
+ style_option.to_sym
72
+ end
73
+ else
74
+ style_option
75
+ end
76
+ end
77
+
78
+ def run_daemon_mode(options, parsed_style)
79
+ # For daemon mode, detach the process
80
+ PrgCLI.daemonize
81
+
82
+ pid_file = options[:pid_file] || '/tmp/ruby-progress/fill.pid'
83
+ FileUtils.mkdir_p(File.dirname(pid_file))
84
+ File.write(pid_file, Process.pid.to_s)
85
+
86
+ # Create the fill bar and show initial empty state
87
+ fill_options = {
88
+ style: parsed_style,
89
+ length: options[:length],
90
+ ends: options[:ends],
91
+ success: options[:success_message],
92
+ error: options[:error_message]
93
+ }
94
+
95
+ fill_bar = Fill.new(fill_options)
96
+ Fill.hide_cursor
97
+
98
+ begin
99
+ fill_bar.render # Show initial empty bar
100
+
101
+ # Set up signal handlers for daemon control
102
+ stop_requested = false
103
+ Signal.trap('INT') { stop_requested = true }
104
+ Signal.trap('USR1') { stop_requested = true }
105
+ Signal.trap('TERM') { stop_requested = true }
106
+
107
+ # Keep daemon alive until stop requested
108
+ sleep(0.1) until stop_requested
109
+ ensure
110
+ Fill.show_cursor
111
+ FileUtils.rm_f(pid_file)
112
+ end
113
+ end
114
+
115
+ def show_current_percentage(options, _parsed_style)
116
+ # Just output the percentage for scripting (default to 50% for demonstration)
117
+ percentage = options[:percent] || 50
118
+ puts percentage.to_f
119
+ end
120
+
121
+ def show_progress_report(options, parsed_style)
122
+ # Create a fill bar to demonstrate current progress
123
+ fill_options = {
124
+ style: parsed_style,
125
+ length: options[:length],
126
+ ends: options[:ends]
127
+ }
128
+
129
+ fill_bar = Fill.new(fill_options)
130
+
131
+ # Set percentage (default to 50% for demonstration)
132
+ fill_bar.percent = options[:percent] || 50
133
+
134
+ # Get detailed report
135
+ report = fill_bar.report
136
+
137
+ # Display the current progress bar and detailed status
138
+ fill_bar.render
139
+ puts "\nProgress Report:"
140
+ puts " Progress: #{report[:progress][0]}/#{report[:progress][1]}"
141
+ puts " Percent: #{report[:percent]}%"
142
+ puts " Completed: #{report[:completed] ? 'Yes' : 'No'}"
143
+ puts " Style: #{report[:style]}"
144
+ end
145
+
146
+ def handle_progress_commands(_options, _parsed_style)
147
+ # For progress commands, we assume there's a daemon running
148
+ # This is a simplified version - in a real implementation,
149
+ # we'd need IPC to communicate with the daemon
150
+ warn 'Progress commands require daemon mode implementation'
151
+ warn "Run 'prg fill --daemon' first, then use progress commands"
152
+ exit 1
153
+ end
154
+
155
+ def run_auto_advance_mode(options, parsed_style)
156
+ fill_options = {
157
+ style: parsed_style,
158
+ length: options[:length],
159
+ ends: options[:ends],
160
+ success: options[:success_message],
161
+ error: options[:error_message]
162
+ }
163
+
164
+ # If a command is provided, capture its output and pass an OutputCapture
165
+ if options[:command]
166
+ oc = RubyProgress::OutputCapture.new(
167
+ command: options[:command],
168
+ lines: options[:output_lines] || 3,
169
+ position: options[:output_position] || :above
170
+ )
171
+ oc.start
172
+ fill_options[:output_capture] = oc
173
+ end
174
+
175
+ fill_bar = Fill.new(fill_options)
176
+ Fill.hide_cursor
177
+
178
+ begin
179
+ if options[:percent]
180
+ # Set to specific percentage
181
+ fill_bar.percent = options[:percent]
182
+ fill_bar.render
183
+ unless fill_bar.completed?
184
+ # For non-complete percentages, show the result briefly
185
+ sleep(0.1)
186
+ end
187
+ elsif options[:command]
188
+ # While the command runs, keep redrawing the bar (live redraw handled by Fill#render)
189
+ sleep_time = case options[:speed]
190
+ when :fast then 0.1
191
+ when :medium, nil then 0.2
192
+ when :slow then 0.5
193
+ when Numeric then 1.0 / options[:speed]
194
+ else 0.3
195
+ end
196
+
197
+ fill_bar.render
198
+ # Loop until the OutputCapture reader has finished
199
+ while oc.alive?
200
+ sleep(sleep_time)
201
+ fill_bar.render
202
+ end
203
+ else
204
+ # Auto-advance mode
205
+ sleep_time = case options[:speed]
206
+ when :fast then 0.1
207
+ when :medium, nil then 0.2
208
+ when :slow then 0.5
209
+ when Numeric then 1.0 / options[:speed]
210
+ else 0.3
211
+ end
212
+
213
+ fill_bar.render
214
+ (1..options[:length]).each do
215
+ sleep(sleep_time)
216
+ fill_bar.advance
217
+ end
218
+ end
219
+ fill_bar.complete
220
+ rescue Interrupt
221
+ fill_bar.cancel
222
+ ensure
223
+ # Ensure we wait for capture thread to finish and show cursor
224
+ oc&.wait
225
+ Fill.show_cursor
226
+ end
227
+ end
228
+
229
+ def show_fill_styles
230
+ puts "\nAvailable Fill Styles:"
231
+ puts '=' * 50
232
+
233
+ Fill::FILL_STYLES.each do |name, style|
234
+ print "#{name.to_s.ljust(12)} : "
235
+
236
+ # Show a sample progress bar
237
+ filled = style[:full] * 6
238
+ empty = style[:empty] * 4
239
+ puts "[#{filled}#{empty}] (60%)"
240
+ end
241
+
242
+ puts "\nCustom Style:"
243
+ puts "#{'custom=XY'.ljust(12)} : Specify X=empty, Y=full characters"
244
+ puts ' Example: --style custom=.# → [######....] (60%)'
245
+ puts
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pty'
4
+ require 'io/console'
5
+ require 'English'
6
+
7
+ 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.
10
+ class OutputCapture
11
+ attr_reader :exit_status
12
+
13
+ def initialize(command:, lines: 3, position: :above, log_path: nil)
14
+ @command = command
15
+ @lines = lines
16
+ @position = position
17
+ @buffer = []
18
+ @buf_mutex = Mutex.new
19
+ @stop = false
20
+ @log_path = log_path
21
+ @log_file = nil
22
+ end
23
+
24
+ # Start the child process and return a thread that manages capture.
25
+ def start
26
+ @reader_thread = Thread.new { spawn_and_read }
27
+ self
28
+ end
29
+
30
+ def stop
31
+ @stop = true
32
+ @reader_thread&.join
33
+ end
34
+
35
+ # Wait for the reader thread to complete
36
+ def wait
37
+ @reader_thread&.join
38
+ end
39
+
40
+ # Return snapshot of buffered lines (thread-safe)
41
+ def lines
42
+ @buf_mutex.synchronize { @buffer.dup }
43
+ end
44
+
45
+ # Returns whether the reader thread is still alive
46
+ def alive?
47
+ @reader_thread&.alive? || false
48
+ end
49
+
50
+ # Redraw buffered output into the terminal above/below the current cursor
51
+ # io - IO object to write to (default $stderr)
52
+ def redraw(io = $stderr)
53
+ buf = lines
54
+ _rows, cols = IO.console.winsize
55
+
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
61
+ end
62
+
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"
71
+ end
72
+ io.print "\e[u" # restore
73
+ io.flush
74
+ end
75
+
76
+ private
77
+
78
+ def spawn_and_read
79
+ PTY.spawn(@command) do |reader, _writer, pid|
80
+ @child_pid = pid
81
+ until reader.eof? || @stop
82
+ next unless reader.wait_readable(0.1)
83
+
84
+ chunk = reader.read_nonblock(4096, exception: false)
85
+ next if chunk.nil? || chunk.empty?
86
+
87
+ # lazily open log file if requested
88
+ if @log_path && !@log_file
89
+ begin
90
+ FileUtils.mkdir_p(File.dirname(@log_path))
91
+ @log_file = File.open(@log_path, 'a')
92
+ rescue StandardError
93
+ @log_file = nil
94
+ end
95
+ end
96
+
97
+ process_chunk(chunk)
98
+ next unless @log_file
99
+
100
+ begin
101
+ @log_file.write(chunk)
102
+ @log_file.flush
103
+ rescue StandardError
104
+ # ignore logging errors
105
+ end
106
+ end
107
+ end
108
+ begin
109
+ Process.wait(@child_pid) if @child_pid
110
+ @exit_status = $CHILD_STATUS.exitstatus if $CHILD_STATUS
111
+ rescue StandardError
112
+ @exit_status = nil
113
+ ensure
114
+ if @log_file
115
+ begin
116
+ @log_file.close
117
+ rescue StandardError
118
+ nil
119
+ end
120
+ end
121
+ end
122
+ rescue Errno::EIO
123
+ # PTY finished
124
+ end
125
+
126
+ def process_chunk(chunk)
127
+ @buf_mutex.synchronize do
128
+ # split into lines, keep last N lines
129
+ chunk.each_line do |line|
130
+ @buffer << line.chomp
131
+ @buffer.shift while @buffer.size > @lines
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -140,6 +140,7 @@ module RubyProgress
140
140
  char = @rainbow ? char.rainbow(i) : char.extend(StringExtensions).light_white
141
141
  post = letters.slice!(0, letters.length).join.extend(StringExtensions).dark_white
142
142
  end
143
+ @output_capture&.redraw($stderr)
143
144
  $stderr.print "\r\e[2K#{@start_chars}#{pre}#{char}#{post}#{@end_chars}"
144
145
  $stderr.flush
145
146
  end
@@ -49,9 +49,15 @@ module RubyProgress
49
49
  when :stderr
50
50
  warn formatted_message
51
51
  when :warn
52
- warn "\e[2K#{formatted_message}"
52
+ # Ensure we're at the beginning of a fresh line, clear it, then display message
53
+ $stderr.print "\r\e[2K"
54
+ $stderr.flush
55
+ warn formatted_message
53
56
  else
54
- warn "\e[2K#{formatted_message}"
57
+ # Ensure we're at the beginning of a fresh line, clear it, then display message
58
+ $stderr.print "\r\e[2K"
59
+ $stderr.flush
60
+ warn formatted_message
55
61
  end
56
62
  end
57
63
 
@@ -77,5 +83,13 @@ module RubyProgress
77
83
 
78
84
  [start_chars, end_chars]
79
85
  end
86
+
87
+ # Validate ends string: must be non-empty and even-length (handles multi-byte chars)
88
+ def self.ends_valid?(ends_string)
89
+ return false unless ends_string && !ends_string.empty?
90
+
91
+ chars = ends_string.each_char.to_a
92
+ !chars.empty? && (chars.length % 2).zero?
93
+ end
80
94
  end
81
95
  end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyProgress
4
- VERSION = '1.2.0'
5
- WORM_VERSION = '1.1.0'
6
- TWIRL_VERSION = '1.1.0'
7
- RIPPLE_VERSION = '1.1.0'
4
+ # Main gem version
5
+ VERSION = '1.3.1'
6
+
7
+ # Component-specific versions (patch bumps)
8
+ WORM_VERSION = '1.1.3'
9
+ TWIRL_VERSION = '1.1.3'
10
+ RIPPLE_VERSION = '1.1.3'
11
+ FILL_VERSION = '1.0.3'
8
12
  end
@@ -4,6 +4,7 @@ require 'optparse'
4
4
  require 'open3'
5
5
  require 'json'
6
6
  require_relative 'utils'
7
+ require_relative 'cli/worm_runner'
7
8
 
8
9
  module RubyProgress
9
10
  # Animated progress indicator with ripple effect using Unicode combining characters
@@ -68,183 +69,7 @@ module RubyProgress
68
69
  @start_chars, @end_chars = RubyProgress::Utils.parse_ends(options[:ends])
69
70
  @running = false
70
71
  end
71
-
72
- def animate(message: nil, success: nil, error: nil, &block)
73
- @message = message if message
74
- @success_text = success if success
75
- @error_text = error if error
76
- @running = true
77
-
78
- # Set up interrupt handler to ensure cursor is restored
79
- original_int_handler = Signal.trap('INT') do
80
- @running = false
81
- RubyProgress::Utils.clear_line
82
- RubyProgress::Utils.show_cursor
83
- exit 130
84
- end
85
-
86
- # Hide cursor
87
- RubyProgress::Utils.hide_cursor
88
-
89
- animation_thread = Thread.new { animation_loop }
90
-
91
- begin
92
- if block_given?
93
- result = yield
94
- @running = false
95
- animation_thread.join
96
- display_completion_message(@success_text, true)
97
- result
98
- else
99
- animation_thread.join
100
- end
101
- rescue StandardError => e
102
- @running = false
103
- animation_thread.join
104
- display_completion_message(@error_text || "Error: #{e.message}", false)
105
- nil # Return nil instead of re-raising when used as a progress indicator
106
- ensure
107
- # Always clear animation line and restore cursor
108
- $stderr.print "\r\e[2K"
109
- RubyProgress::Utils.show_cursor
110
- Signal.trap('INT', original_int_handler) if original_int_handler
111
- end
112
- end
113
-
114
- def run_with_command
115
- return unless @command
116
-
117
- exit_code = 0
118
- stdout_content = nil
119
-
120
- begin
121
- stdout_content = animate do
122
- # Use popen3 instead of capture3 for better signal handling
123
- Open3.popen3(@command) do |stdin, stdout, stderr, wait_thr|
124
- stdin.close
125
- captured_stdout = stdout.read
126
- stderr_content = stderr.read
127
- exit_code = wait_thr.value.exitstatus
128
-
129
- unless wait_thr.value.success?
130
- error_msg = @error_text || "Command failed with exit code #{exit_code}"
131
- error_msg += ": #{stderr_content.strip}" if stderr_content && !stderr_content.empty?
132
- raise StandardError, error_msg
133
- end
134
- captured_stdout
135
- end
136
- end
137
-
138
- # Output to stdout if --stdout flag is set
139
- puts stdout_content if @output_stdout && stdout_content
140
- rescue StandardError => e
141
- # animate method handles error display, just exit with proper code
142
- exit exit_code.nonzero? || 1
143
- rescue Interrupt
144
- exit 130
145
- end
146
- end
147
-
148
- def run_indefinitely
149
- # Set up interrupt handler to ensure cursor is restored
150
- original_int_handler = Signal.trap('INT') do
151
- @running = false
152
- RubyProgress::Utils.clear_line
153
- RubyProgress::Utils.show_cursor
154
- exit 130
155
- end
156
-
157
- @running = true
158
- RubyProgress::Utils.hide_cursor
159
-
160
- begin
161
- animation_loop
162
- ensure
163
- RubyProgress::Utils.show_cursor
164
- Signal.trap('INT', original_int_handler) if original_int_handler
165
- end
166
- end
167
-
168
- def stop
169
- @running = false
170
- end
171
-
172
- def run_daemon_mode(success_message: nil, show_checkmark: false, control_message_file: nil)
173
- @running = true
174
- stop_requested = false
175
-
176
- # Set up signal handlers
177
- original_int_handler = Signal.trap('INT') { stop_requested = true }
178
- Signal.trap('USR1') { stop_requested = true }
179
- Signal.trap('TERM') { stop_requested = true }
180
- Signal.trap('HUP') { stop_requested = true }
181
-
182
- RubyProgress::Utils.hide_cursor
183
-
184
- begin
185
- animation_loop_daemon_mode(stop_requested_proc: -> { stop_requested })
186
- ensure
187
- RubyProgress::Utils.clear_line
188
- RubyProgress::Utils.show_cursor
189
-
190
- # Display stop-time completion message, preferring control file if provided
191
- final_message = success_message
192
- final_checkmark = show_checkmark
193
- final_success = true
194
- if control_message_file && File.exist?(control_message_file)
195
- begin
196
- data = JSON.parse(File.read(control_message_file))
197
- final_message = data['message'] if data['message']
198
- final_checkmark = !!data['checkmark'] if data.key?('checkmark')
199
- final_success = !!data['success'] if data.key?('success')
200
- rescue StandardError
201
- # ignore parse errors, fallback to provided message
202
- ensure
203
- begin
204
- File.delete(control_message_file)
205
- rescue StandardError
206
- nil
207
- end
208
- end
209
- end
210
-
211
- if final_message
212
- RubyProgress::Utils.display_completion(
213
- final_message,
214
- success: final_success,
215
- show_checkmark: final_checkmark,
216
- output_stream: :stdout
217
- )
218
- end
219
-
220
- Signal.trap('INT', original_int_handler) if original_int_handler
221
- end
222
- end
223
-
224
- def animation_loop_step
225
- return unless @running
226
-
227
- @position ||= 0
228
- @direction ||= 1
229
-
230
- message_part = @message && !@message.empty? ? "#{@message} " : ''
231
- $stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(@position, @direction)}#{@end_chars}"
232
- $stderr.flush
233
-
234
- sleep @speed
235
-
236
- # Update position and direction
237
- @position += @direction
238
- if @position >= @length - 1
239
- if @direction_mode == :forward_only
240
- @position = 0
241
- else
242
- @direction = -1
243
- end
244
- elsif @position <= 0
245
- @direction = 1
246
- end
247
- end
72
+ include WormRunner
248
73
 
249
74
  private
250
75
 
data/lib/ruby-progress.rb CHANGED
@@ -4,6 +4,7 @@ require_relative 'ruby-progress/version'
4
4
  require_relative 'ruby-progress/utils'
5
5
  require_relative 'ruby-progress/ripple'
6
6
  require_relative 'ruby-progress/worm'
7
+ require_relative 'ruby-progress/fill'
7
8
  require_relative 'ruby-progress/daemon'
8
9
 
9
10
  module RubyProgress