ruby-progress 1.1.4 → 1.1.9

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.
data/blog-post.md ADDED
@@ -0,0 +1,174 @@
1
+ ---
2
+ title: "More CLI Progress Indicators: The ruby-progress Gem"
3
+ date: 2025-10-10
4
+ categories: [Ruby, CLI, Development Tools]
5
+ tags: [ruby, cli, progress, animation, terminal]
6
+ ---
7
+
8
+ [ripple]: https://brettterpstra.com/2025/06/30/ripple-an-indeterminate-progress-indicator/
9
+
10
+ # More CLI Progress Indicators: The ruby-progress Gem
11
+
12
+ The `ruby-progress` gem expands my previous work on [Ripple], adding 2 more indicator types and a new daemon mode to make it more useful in non-Ruby shell scripts.
13
+
14
+ ## What is ruby-progress?
15
+
16
+ Ruby-progress provides animated progress indicators for command-line applications. Unlike traditional progress bars showing completion percentage, this gem focuses on continuous animations (indeterminate) that indicate activity - perfect for tasks with unpredictable completion times.
17
+
18
+ Three animation styles are available:
19
+
20
+ - **Ripple**: Wave-like effect across text
21
+ - **Worm**: Moving dot pattern that crawls across the screen
22
+ - **Twirl**: Classic spinning indicators with various symbols
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ # Install
28
+ gem install ruby-progress
29
+
30
+ # Basic usage
31
+ ripple "Processing files..."
32
+ worm --message "Loading..." --speed fast
33
+ twirl --spinner dots
34
+ ```
35
+
36
+ ## Ruby Integration
37
+
38
+ ```ruby
39
+ require 'ruby-progress'
40
+
41
+ # Block syntax with automatic cleanup
42
+ RubyProgress::Ripple.progress("Processing files...") do
43
+ process_files
44
+ upload_results
45
+ end
46
+
47
+ # Manual control
48
+ worm = RubyProgress::Worm.new(
49
+ message: "Custom task",
50
+ style: 'blocks',
51
+ speed: 'medium'
52
+ )
53
+
54
+ worm.animate { heavy_computation }
55
+ ```
56
+
57
+ ## Daemon Mode - The Killer Feature
58
+
59
+ Run progress indicators in the background while your scripts execute:
60
+
61
+ ```bash
62
+ # Start background indicator
63
+ worm --daemon --pid-file /tmp/progress.pid --message "Deploying..."
64
+
65
+ # Run your actual work
66
+ ./deploy.sh
67
+ kubectl apply -f manifests/
68
+
69
+ # Stop with success message
70
+ worm --stop /tmp/progress.pid --message "Deploy complete!" --checkmark
71
+ ```
72
+
73
+ This is incredibly useful for complex deployment scripts where you want continuous visual feedback without interrupting the main process.
74
+
75
+ There's a unified binary called `prg` that takes the three types as subcommands, e.g. `prg twirl --checkmark`. You can use any of them either way.
76
+
77
+ ## Advanced Features
78
+
79
+ ### Error Handling
80
+ ```ruby
81
+ RubyProgress::Ripple.progress("Risky operation") do
82
+ might_fail_operation
83
+ rescue StandardError => e
84
+ # Automatically stops and shows error state
85
+ puts "Failed: #{e.message}"
86
+ end
87
+ ```
88
+
89
+ ### Command Integration
90
+ ```ruby
91
+ # Execute shell commands with progress
92
+ RubyProgress::Worm.new(
93
+ command: "pg_dump mydb > backup.sql",
94
+ success: "Backup complete!",
95
+ error: "Backup failed"
96
+ ).run_with_command
97
+ ```
98
+
99
+ ### Visual Customization
100
+ ```ruby
101
+ # Rainbow colors and custom styling
102
+ RubyProgress::Ripple.progress("Colorful task",
103
+ rainbow: true,
104
+ speed: :fast,
105
+ format: :forward_only
106
+ ) do
107
+ process_with_style
108
+ end
109
+ ```
110
+
111
+ ## Real-World Examples
112
+
113
+ ### Deployment Script
114
+
115
+ ```bash
116
+ #!/bin/bash
117
+ worm --daemon-as deploy --message "Deploying..." &
118
+
119
+ git push production main
120
+ kubectl rollout status deployment/app
121
+
122
+ worm --stop-id deploy --stop-success "Success!" --checkmark
123
+ ```
124
+
125
+ ### Data Processing
126
+ ```ruby
127
+ def import_large_csv(file)
128
+ RubyProgress::Ripple.progress("Importing #{File.basename(file)}...") do
129
+ CSV.foreach(file, headers: true) { |row| User.create!(row.to_h) }
130
+ end
131
+ end
132
+ ```
133
+
134
+ ### Rake Tasks
135
+ ```ruby
136
+ task :backup do
137
+ RubyProgress::Worm.new(
138
+ command: "tar -czf backup.tar.gz app/",
139
+ success: "Backup created successfully!"
140
+ ).run_with_command
141
+ end
142
+ ```
143
+
144
+ ## Why ruby-progress?
145
+
146
+ - **Simple API**: Works in Ruby code and shell scripts
147
+ - **Visual Appeal**: Engaging animations beyond basic spinners
148
+ - **Unique Daemon Mode**: Background progress indicators
149
+ - **Production Ready**: 84.55% test coverage, 113 test examples, zero failures
150
+ - **Cross-Platform**: Linux, macOS, Windows support
151
+ - **Reliable**: Comprehensive error handling and edge case coverage
152
+
153
+ ## Installation & First Steps
154
+
155
+ 1. **Install**: `gem install ruby-progress`
156
+ 2. **Test CLI**: `ripple "Hello World!"`
157
+ 3. **Try in Ruby**:
158
+ ```ruby
159
+ require 'ruby-progress'
160
+ RubyProgress::Ripple.progress("Testing...") { sleep 2 }
161
+ ```
162
+
163
+ ## Conclusion
164
+
165
+ Ruby-progress transforms boring CLI applications into engaging user experiences. Whether building deployment scripts, data processing tools, or utility commands, it provides the visual feedback users expect from modern command-line tools.
166
+
167
+ The daemon mode alone makes it worth trying - no other progress library offers this level of flexibility for complex workflows.
168
+
169
+ **Links:**
170
+ - [GitHub](https://github.com/ttscoff/ruby-progress)
171
+ - [RubyGems](https://rubygems.org/gems/ruby-progress)
172
+ - [Latest Release](https://github.com/ttscoff/ruby-progress/releases/latest)
173
+
174
+ *Make your CLI tools feel alive with ruby-progress!*
@@ -18,7 +18,7 @@ puts "PID file: #{pid_file}"
18
18
  puts
19
19
 
20
20
  # Clean up any existing PID file
21
- File.delete(pid_file) if File.exist?(pid_file)
21
+ FileUtils.rm_f(pid_file)
22
22
 
23
23
  puts '1. Starting worm progress indicator in daemon mode...'
24
24
  bin_path = File.join(File.dirname(__dir__), 'bin', 'prg')
@@ -0,0 +1,55 @@
1
+ # Experimental terminal line protection for daemon mode
2
+ # This demonstrates potential solutions for the daemon output interruption problem
3
+
4
+ module RubyProgress
5
+ module SmartTerminal
6
+ # Save current cursor position
7
+ def self.save_cursor_position
8
+ $stderr.print "\e[s" # Save cursor position
9
+ $stderr.flush
10
+ end
11
+
12
+ # Restore cursor to saved position
13
+ def self.restore_cursor_position
14
+ $stderr.print "\e[u" # Restore cursor position
15
+ $stderr.flush
16
+ end
17
+
18
+ # Get current cursor position (requires terminal interaction)
19
+ def self.get_cursor_position
20
+ # This is complex and requires reading from stdin
21
+ # which may not work reliably in daemon mode
22
+ $stderr.print "\e[6n" # Request cursor position
23
+ $stderr.flush
24
+ # Would need to read response: "\e[{row};{col}R"
25
+ # But this requires terminal input capability
26
+ end
27
+
28
+ # Alternative: Use absolute positioning
29
+ def self.position_cursor_absolute(row, col)
30
+ $stderr.print "\e[#{row};#{col}H"
31
+ $stderr.flush
32
+ end
33
+
34
+ # Enhanced line clearing that works from any position
35
+ def self.clear_current_line_and_reposition
36
+ $stderr.print "\r" # Move to start of line
37
+ $stderr.print "\e[2K" # Clear entire line
38
+ $stderr.print "\e[1A" # Move up one line (in case we're on a new line)
39
+ $stderr.print "\r" # Move to start again
40
+ $stderr.print "\e[2K" # Clear that line too
41
+ $stderr.flush
42
+ end
43
+
44
+ # Experimental: Try to "reclaim" the animation line
45
+ def self.reclaim_animation_line(animation_text)
46
+ # Strategy: Clear multiple lines and reposition
47
+ $stderr.print "\r" # Go to start of current line
48
+ $stderr.print "\e[2K" # Clear current line
49
+ $stderr.print "\e[1A" # Move up one line
50
+ $stderr.print "\e[2K" # Clear that line
51
+ $stderr.print animation_text # Print our animation
52
+ $stderr.flush
53
+ end
54
+ end
55
+ end
@@ -45,10 +45,10 @@ module RubyProgress
45
45
  begin
46
46
  Process.kill('USR1', pid)
47
47
  sleep 0.5
48
- File.delete(pid_file) if File.exist?(pid_file)
48
+ FileUtils.rm_f(pid_file)
49
49
  rescue Errno::ESRCH
50
50
  puts "Process #{pid} not found (may have already stopped)"
51
- File.delete(pid_file) if File.exist?(pid_file)
51
+ FileUtils.rm_f(pid_file)
52
52
  exit 1
53
53
  rescue Errno::EPERM
54
54
  puts "Permission denied sending signal to process #{pid}"
@@ -31,57 +31,27 @@ module RubyProgress
31
31
  INDICATORS = {
32
32
  arc: %w[◜ ◠ ◝ ◞ ◡ ◟],
33
33
  arrow: %w[← ↖ ↑ ↗ → ↘ ↓ ↙],
34
- arrow_pulse: [
35
- '▹▹▹▹▹',
36
- '▸▹▹▹▹',
37
- '▹▸▹▹▹',
38
- '▹▹▸▹▹',
39
- '▹▹▹▸▹',
40
- '▹▹▹▹▸'
41
- ],
42
- balloon: %w[. o O ° O o .],
43
- block_1: %w[▖▖▖ ▘▖▖ ▖▘▖ ▖▖▘],
44
34
  block_2: %w[▌ ▀ ▐ ▄],
45
- bounce: [
46
- '[ ]',
47
- '[= ]',
48
- '[== ]',
49
- '[=== ]',
50
- '[====]'
51
- ],
52
- circle: %w[○○○ ●○○ ○●○ ○○●],
35
+ block_1: %w[▖▖▖ ▘▖▖ ▖▘▖ ▖▖▘],
36
+ bounce: %w[⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈],
53
37
  classic: ['|', '/', '—', '\\'],
54
38
  dots: %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏],
55
39
  dots_2: %w[⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷],
56
40
  dots_3: %w[⠋ ⠙ ⠚ ⠞ ⠖ ⠦ ⠴ ⠲ ⠳ ⠓],
57
- dots_4: %w[ ⠠ ⠰ ⠸ ⠙ ⠋ ⠇ ⠆],
58
- dots_5: %w[⠋ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋],
59
- dots_6: %w[⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠴ ⠲ ⠒ ⠂ ⠂ ⠒ ⠚ ⠙ ⠉ ⠁],
60
- dots_7: %w[⠈ ⠉ ⠋ ⠓ ⠒ ⠐ ⠐ ⠒ ⠖ ⠦ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈],
61
- dots_8: %w[⠁ ⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈ ⠈],
62
- dots_9: %w[⢹ ⢺ ⢼ ⣸ ⣇ ⡧ ⡗ ⡏],
63
- dots_10: %w[⢄ ⢂ ⢁ ⡁ ⡈ ⡐ ⡠],
64
- dots_11: %w[⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈],
41
+ dots_4: %w[ ],
65
42
  ellipsis: ['. ', '.. ', '... ', '....'],
66
- lighthouse: ['∙∙∙', '●∙∙', '∙●∙', '∙∙●'],
67
- o: %w[Ooo oOo ooO],
68
43
  pipe: %w[┤ ┘ ┴ └ ├ ┌ ┬ ┐],
69
44
  pulse: %w[⎺ ⎻ ⎼ ⎽ ⎼ ⎻],
70
45
  pulse_2: %w[▁ ▃ ▅ ▆ ▇ █ ▇ ▆ ▅ ▃],
71
46
  pulse_3: %w[▉ ▊ ▋ ▌ ▍ ▎ ▏ ▎ ▍ ▌ ▋ ▊ ▉],
72
47
  pulse_4: %w[- = ≡ = -],
73
- push: [
74
- '[> ]',
75
- '[=> ]',
76
- '[==> ]',
77
- '[===> ]',
78
- '[====>]'
79
- ],
48
+ o: %w[Ooo oOo ooO],
80
49
  spin: %w[◴ ◷ ◶ ◵],
81
50
  spin_2: %w[◐ ◓ ◑ ◒],
82
51
  spin_3: %w[◰ ◳ ◲ ◱],
83
52
  toggle: %w[■ □ ▪ ▫],
84
- triangle: %w[◢ ◣ ◤ ◥]
53
+ triangle: %w[◢ ◣ ◤ ◥],
54
+ twinkle: %w[⢄ ⢂ ⢁ ⡁ ⡈ ⡐ ⡠]
85
55
  }.freeze
86
56
 
87
57
  # String extensions for color support
@@ -93,7 +63,7 @@ module RubyProgress
93
63
  end
94
64
 
95
65
  def rainbow(index = 0)
96
- chars = split('')
66
+ chars = self.chars
97
67
  colored_chars = chars.map.with_index do |char, idx|
98
68
  color = COLORS.values[(idx + index) % COLORS.size]
99
69
  "#{color}#{char}#{COLORS['reset']}"
@@ -104,7 +74,7 @@ module RubyProgress
104
74
  def normalize_type
105
75
  spinner_type = :classic
106
76
  INDICATORS.each do |spinner, _v|
107
- spinner_type = spinner if spinner =~ /^#{split('').join('.*?')}/i
77
+ spinner_type = spinner if spinner =~ /^#{chars.join('.*?')}/i
108
78
  end
109
79
  spinner_type
110
80
  end
@@ -137,7 +107,7 @@ module RubyProgress
137
107
  end
138
108
 
139
109
  def printout
140
- letters = @string.dup.split('')
110
+ letters = @string.dup.chars
141
111
  i = @index
142
112
  if @spinner
143
113
  case @spinner_position
@@ -168,7 +138,8 @@ module RubyProgress
168
138
  char = @rainbow ? char.rainbow(i) : char.extend(StringExtensions).light_white
169
139
  post = letters.slice!(0, letters.length).join.extend(StringExtensions).dark_white
170
140
  end
171
- $stderr.print "\e[2K#{pre}#{char}#{post}\r"
141
+ $stderr.print "\r\e[2K#{pre}#{char}#{post}"
142
+ $stderr.flush
172
143
  end
173
144
 
174
145
  # Hide or show the cursor (delegated to Utils)
@@ -12,8 +12,20 @@ module RubyProgress
12
12
  $stderr.print "\e[?25h"
13
13
  end
14
14
 
15
- def self.clear_line
16
- print "\r\e[K"
15
+ def self.clear_line(output_stream = :stderr)
16
+ case output_stream
17
+ when :stdout
18
+ $stdout.print "\r\e[K"
19
+ else
20
+ $stderr.print "\r\e[K"
21
+ end
22
+ end
23
+
24
+ # Enhanced line clearing for daemon mode that handles output interruption
25
+ def self.clear_line_aggressive
26
+ $stderr.print "\r\e[2K" # Clear entire current line
27
+ $stderr.print "\e[1A\e[2K" # Move up one line and clear it too
28
+ $stderr.print "\r" # Return to start of line
17
29
  end
18
30
 
19
31
  # Universal completion message display
@@ -46,7 +58,7 @@ module RubyProgress
46
58
  # Clear current line and display completion message
47
59
  # Convenience method that combines line clearing with message display
48
60
  def self.complete_with_clear(message, success: true, show_checkmark: false, output_stream: :warn)
49
- clear_line if output_stream != :warn # warn already includes clear in display_completion
61
+ clear_line(output_stream) if output_stream != :warn # warn already includes clear in display_completion
50
62
  display_completion(message, success: success, show_checkmark: show_checkmark, output_stream: output_stream)
51
63
  end
52
64
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyProgress
4
- VERSION = '1.1.4'
4
+ VERSION = '1.1.9'
5
+ WORM_VERSION = '1.0.4'
6
+ TWIRL_VERSION = '1.0.1'
7
+ RIPPLE_VERSION = '1.0.5'
5
8
  end
@@ -3,6 +3,7 @@
3
3
  require 'optparse'
4
4
  require 'open3'
5
5
  require 'json'
6
+ require_relative 'utils'
6
7
 
7
8
  module RubyProgress
8
9
  # Animated progress indicator with ripple effect using Unicode combining characters
@@ -23,6 +24,26 @@ module RubyProgress
23
24
  baseline: '▪', # small black square
24
25
  midline: '▫', # small white square
25
26
  peak: '■' # large black square
27
+ },
28
+ 'cirlces_small' => {
29
+ baseline: '∙',
30
+ midline: '∙',
31
+ peak: '●'
32
+ },
33
+ 'arrow' => {
34
+ baseline: '▹',
35
+ midline: '▸',
36
+ peak: '▶'
37
+ },
38
+ 'balloon' => {
39
+ baseline: '.',
40
+ midline: 'o',
41
+ peak: '°'
42
+ },
43
+ 'circle_open' => {
44
+ baseline: '○',
45
+ midline: '●',
46
+ peak: '○'
26
47
  }
27
48
  }.freeze
28
49
 
@@ -57,7 +78,6 @@ module RubyProgress
57
78
  @running = false
58
79
  RubyProgress::Utils.clear_line
59
80
  RubyProgress::Utils.show_cursor
60
- puts "\nInterrupted!"
61
81
  exit 130
62
82
  end
63
83
 
@@ -82,7 +102,8 @@ module RubyProgress
82
102
  display_completion_message(@error_text || "Error: #{e.message}", false)
83
103
  nil # Return nil instead of re-raising when used as a progress indicator
84
104
  ensure
85
- # Always restore cursor and signal handler
105
+ # Always clear animation line and restore cursor
106
+ $stderr.print "\r\e[2K"
86
107
  RubyProgress::Utils.show_cursor
87
108
  Signal.trap('INT', original_int_handler) if original_int_handler
88
109
  end
@@ -118,7 +139,6 @@ module RubyProgress
118
139
  # animate method handles error display, just exit with proper code
119
140
  exit exit_code.nonzero? || 1
120
141
  rescue Interrupt
121
- puts "\nInterrupted!"
122
142
  exit 130
123
143
  end
124
144
  end
@@ -129,7 +149,6 @@ module RubyProgress
129
149
  @running = false
130
150
  RubyProgress::Utils.clear_line
131
151
  RubyProgress::Utils.show_cursor
132
- puts "\nInterrupted!"
133
152
  exit 130
134
153
  end
135
154
 
@@ -161,7 +180,7 @@ module RubyProgress
161
180
  RubyProgress::Utils.hide_cursor
162
181
 
163
182
  begin
164
- animation_loop_step while @running && !stop_requested
183
+ animation_loop_daemon_mode(stop_requested_proc: -> { stop_requested })
165
184
  ensure
166
185
  RubyProgress::Utils.clear_line
167
186
  RubyProgress::Utils.show_cursor
@@ -206,9 +225,9 @@ module RubyProgress
206
225
  @position ||= 0
207
226
  @direction ||= 1
208
227
 
209
- message_part = @message && !@message.empty? ? @message : ''
210
- print "\r#{message_part}#{generate_dots(@position, @direction)}"
211
- $stdout.flush
228
+ message_part = @message && !@message.empty? ? "#{@message} " : ''
229
+ $stderr.print "\r\e[2K#{message_part}#{generate_dots(@position, @direction)}"
230
+ $stderr.flush
212
231
 
213
232
  sleep @speed
214
233
 
@@ -225,13 +244,14 @@ module RubyProgress
225
244
 
226
245
  def display_completion_message(message, success)
227
246
  return unless message
228
-
247
+
229
248
  mark = ''
230
249
  if @show_checkmark
231
250
  mark = success ? '✅ ' : '🛑 '
232
251
  end
233
252
 
234
- puts "#{mark}#{message}"
253
+ # Clear animation line and output completion message on stderr
254
+ $stderr.print "\r\e[2K#{mark}#{message}\n"
235
255
  end
236
256
 
237
257
  def parse_speed(speed_input)
@@ -240,7 +260,7 @@ module RubyProgress
240
260
  if speed_input.match?(/^\d+$/)
241
261
  # Numeric string (1-10)
242
262
  speed_num = speed_input.to_i
243
- return 0.6 - (speed_num - 1) * 0.05 if speed_num.between?(1, 10)
263
+ return 0.6 - ((speed_num - 1) * 0.05) if speed_num.between?(1, 10)
244
264
  end
245
265
 
246
266
  # Check for abbreviated forms
@@ -256,25 +276,67 @@ module RubyProgress
256
276
  end
257
277
  when Numeric
258
278
  speed_num = speed_input.to_i
259
- speed_num.between?(1, 10) ? 0.6 - (speed_num - 1) * 0.05 : SPEED_MAP['medium']
279
+ speed_num.between?(1, 10) ? 0.6 - ((speed_num - 1) * 0.05) : SPEED_MAP['medium']
260
280
  else
261
281
  SPEED_MAP['medium']
262
282
  end
263
283
  end
264
284
 
265
285
  def parse_style(style_input)
266
- return RIPPLE_STYLES['circles'] unless style_input
267
-
268
- style_lower = style_input.to_s.downcase
269
- if style_lower.start_with?('b')
270
- RIPPLE_STYLES['blocks']
271
- elsif style_lower.start_with?('g')
272
- RIPPLE_STYLES['geometric']
273
- elsif style_lower.start_with?('c')
274
- RIPPLE_STYLES['circles']
275
- else
276
- RIPPLE_STYLES['circles'] # default
286
+ return RIPPLE_STYLES['circles'] unless style_input && !style_input.to_s.strip.empty?
287
+
288
+ style_lower = style_input.to_s.downcase.strip
289
+
290
+ # First, try exact match
291
+ return RIPPLE_STYLES[style_lower] if RIPPLE_STYLES.key?(style_lower)
292
+
293
+ # Then try prefix matching - keys that start with the input
294
+ prefix_matches = RIPPLE_STYLES.keys.select do |key|
295
+ key.downcase.start_with?(style_lower)
296
+ end
297
+
298
+ unless prefix_matches.empty?
299
+ # For prefix matches, return the shortest one
300
+ best_match = prefix_matches.min_by(&:length)
301
+ return RIPPLE_STYLES[best_match]
302
+ end
303
+
304
+ # Try character-by-character fuzzy matching for partial inputs
305
+ # Find keys where the input characters appear in order (not necessarily contiguous)
306
+ fuzzy_matches = RIPPLE_STYLES.keys.select do |key|
307
+ key_chars = key.downcase.chars
308
+ input_chars = style_lower.chars
309
+
310
+ # Check if all input characters appear in order in the key
311
+ input_chars.all? do |char|
312
+ idx = key_chars.index(char)
313
+ if idx
314
+ key_chars = key_chars[idx + 1..-1] # Remove matched chars and continue
315
+ true
316
+ else
317
+ false
318
+ end
319
+ end
320
+ end
321
+
322
+ unless fuzzy_matches.empty?
323
+ # Sort by length (prefer shorter keys)
324
+ best_match = fuzzy_matches.min_by(&:length)
325
+ return RIPPLE_STYLES[best_match]
326
+ end
327
+
328
+ # Fallback to substring matching
329
+ substring_matches = RIPPLE_STYLES.keys.select do |key|
330
+ key.downcase.include?(style_lower)
331
+ end
332
+
333
+ unless substring_matches.empty?
334
+ best_match = substring_matches.min_by(&:length)
335
+ return RIPPLE_STYLES[best_match]
277
336
  end
337
+
338
+ # Default fallback
339
+ RIPPLE_STYLES['circles']
278
340
  end
279
341
 
280
342
  def animation_loop
@@ -282,11 +344,45 @@ module RubyProgress
282
344
  direction = 1
283
345
 
284
346
  while @running
285
- message_part = @message && !@message.empty? ? @message : ''
286
- print "\r#{message_part}#{generate_dots(position, direction)}"
287
- $stdout.flush
347
+ message_part = @message && !@message.empty? ? "#{@message} " : ''
348
+ # Enhanced line clearing for better daemon mode behavior
349
+ $stderr.print "\r\e[2K#{message_part}#{generate_dots(position, direction)}"
350
+ $stderr.flush
351
+
352
+ sleep @speed
353
+
354
+ position += direction
355
+ if position >= @length - 1
356
+ direction = -1
357
+ elsif position <= 0
358
+ direction = 1
359
+ end
360
+ end
361
+ end
362
+
363
+ # Enhanced animation loop for daemon mode with aggressive line clearing
364
+ def animation_loop_daemon_mode(stop_requested_proc: -> { false })
365
+ position = 0
366
+ direction = 1
367
+ frame_count = 0
368
+
369
+ while @running && !stop_requested_proc.call
370
+ message_part = @message && !@message.empty? ? "#{@message} " : ''
371
+
372
+ # Always clear current line
373
+ $stderr.print "\r\e[2K"
374
+
375
+ # Every few frames, use aggressive clearing to handle interruptions
376
+ if (frame_count % 10).zero?
377
+ $stderr.print "\e[1A\e[2K" # Move up and clear that line too (in case of interruption)
378
+ $stderr.print "\r" # Return to start
379
+ end
380
+
381
+ $stderr.print "#{message_part}#{generate_dots(position, direction)}"
382
+ $stderr.flush
288
383
 
289
384
  sleep @speed
385
+ frame_count += 1
290
386
 
291
387
  position += direction
292
388
  if position >= @length - 1
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+ # Test script to explore terminal cursor behavior with daemon output interruption
3
+
4
+ require_relative 'lib/ruby-progress'
5
+
6
+ puts 'Testing daemon output interruption behavior...'
7
+ puts 'This will start a daemon and then output some text to see what happens.'
8
+
9
+ # Start a daemon
10
+ system("ruby -I lib bin/prg worm --daemon --message 'Testing daemon animation' &")
11
+
12
+ sleep 1
13
+
14
+ puts 'This output should interrupt the animation'
15
+ puts 'And this is a second line of output'
16
+
17
+ sleep 2
18
+
19
+ puts 'More interrupting output after animation has been running'
20
+
21
+ sleep 2
22
+
23
+ # Stop the daemon
24
+ system('ruby -I lib bin/prg --stop-all')
25
+
26
+ puts 'Test completed'
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Test script to reproduce daemon orphaning issue
4
+ puts 'Starting daemon...'
5
+ system("./bin/prg worm --daemon-as test 'Working on something...'")
6
+
7
+ puts 'Daemon started, sleeping for 3 seconds...'
8
+ puts 'Cancel this script with ^C during the sleep to reproduce the orphan issue'
9
+ sleep 3
10
+
11
+ puts 'Stopping daemon normally...'
12
+ system('./bin/prg --stop-id test')
13
+ puts 'Done'