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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -122
- data/DEMO_SCRIPTS.md +162 -0
- data/Gemfile.lock +1 -1
- data/README.md +201 -62
- data/Rakefile +7 -0
- data/bin/fill +10 -0
- data/bin/prg +50 -1009
- data/demo_screencast.rb +296 -0
- data/experimental_terminal.rb +7 -0
- data/lib/ruby-progress/cli/fill_options.rb +193 -0
- data/lib/ruby-progress/cli/ripple_cli.rb +150 -0
- data/lib/ruby-progress/cli/ripple_options.rb +148 -0
- data/lib/ruby-progress/cli/twirl_cli.rb +173 -0
- data/lib/ruby-progress/cli/twirl_options.rb +136 -0
- data/lib/ruby-progress/cli/twirl_runner.rb +130 -0
- data/lib/ruby-progress/cli/twirl_spinner.rb +78 -0
- data/lib/ruby-progress/cli/worm_cli.rb +75 -0
- data/lib/ruby-progress/cli/worm_options.rb +156 -0
- data/lib/ruby-progress/cli/worm_runner.rb +260 -0
- data/lib/ruby-progress/fill.rb +211 -0
- data/lib/ruby-progress/fill_cli.rb +219 -0
- data/lib/ruby-progress/ripple.rb +4 -2
- data/lib/ruby-progress/utils.rb +32 -2
- data/lib/ruby-progress/version.rb +8 -4
- data/lib/ruby-progress/worm.rb +43 -178
- data/lib/ruby-progress.rb +1 -0
- data/quick_demo.rb +134 -0
- data/readme_demo.rb +128 -0
- data/ruby-progress.gemspec +40 -0
- data/scripts/run_matrix_mise.fish +41 -0
- data/test_daemon_interruption.rb +2 -0
- data/test_daemon_orphan.rb +1 -0
- metadata +21 -1
|
@@ -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
|